diff --git a/TestFx.slnx b/TestFx.slnx index b203218a4f..77d5f93410 100644 --- a/TestFx.slnx +++ b/TestFx.slnx @@ -40,6 +40,7 @@ + diff --git a/changes.patch b/changes.patch new file mode 100644 index 0000000000..b958ac7eb0 --- /dev/null +++ b/changes.patch @@ -0,0 +1,10187 @@ +From 470f5cb5f6ba02795189b14609edfee59d5a6698 Mon Sep 17 00:00:00 2001 +From: Azat Mukhametshin +Date: Tue, 16 Jun 2026 00:20:33 +0200 +Subject: [PATCH 01/32] MSTest migrated + +--- + src/Package/MSTest.Sdk/MSTest.Sdk.csproj | 15 +++++++-------- + src/Package/MSTest.Sdk/MSTest.Sdk.nuspec | 21 --------------------- + 2 files changed, 7 insertions(+), 29 deletions(-) + delete mode 100644 src/Package/MSTest.Sdk/MSTest.Sdk.nuspec + +diff --git a/src/Package/MSTest.Sdk/MSTest.Sdk.csproj b/src/Package/MSTest.Sdk/MSTest.Sdk.csproj +index a9207301b1..b48f0bb43c 100644 +--- a/src/Package/MSTest.Sdk/MSTest.Sdk.csproj ++++ b/src/Package/MSTest.Sdk/MSTest.Sdk.csproj +@@ -7,25 +7,24 @@ + + true + MSTest.Sdk +- MSTest.Sdk.nuspec +- $(OutputPath) + MSTest TestFramework TestAdapter VisualStudio Unittest MSTestV2 Microsoft + + MSTest is Microsoft supported Test Framework. + + This package contains the MSTest MSBuild project SDK. + ++ PACKAGE.md + + $(NoWarn);NU5128 ++ ++ false + + +- +- +- +- +- + +- ++ ++ PreserveNewest ++ ++ + PreserveNewest + + +diff --git a/src/Package/MSTest.Sdk/MSTest.Sdk.nuspec b/src/Package/MSTest.Sdk/MSTest.Sdk.nuspec +deleted file mode 100644 +index 1035b41731..0000000000 +--- a/src/Package/MSTest.Sdk/MSTest.Sdk.nuspec ++++ /dev/null +@@ -1,21 +0,0 @@ +- +- +- +- $CommonMetadataElements$ +- PACKAGE.md +- +- +- $CommonFileElements$ +- +- +- +- +- +- +- +- +- +- +- +- +- + +From 4ad397bbdd3c24874518b00b1cfe82b2f2b16d18 Mon Sep 17 00:00:00 2001 +From: Azat Mukhametshin +Date: Mon, 29 Jun 2026 13:16:18 +0200 +Subject: [PATCH 02/32] Add Microsoft.Testing.Extensions.GitHubActionsReport + with per-assembly log groups + +Implements the first requirement of #9142: a new GitHub Actions report extension that emits ::group:: / ::endgroup:: workflow commands per test assembly so the runner UI collapses each assembly's output. Auto-activates when GITHUB_ACTIONS=true; controllable via --report-gh-groups on/off and --report-gh. + +Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> +--- + TestFx.slnx | 1 + + .../BannedSymbols.txt | 9 ++ + .../GitHubActionsCommandLineOptions.cs | 12 +++ + .../GitHubActionsCommandLineProvider.cs | 35 ++++++++ + .../GitHubActionsEscaper.cs | 43 +++++++++ + .../GitHubActionsExtensions.cs | 33 +++++++ + .../GitHubActionsReporter.cs | 87 +++++++++++++++++++ + ...ting.Extensions.GitHubActionsReport.csproj | 59 +++++++++++++ + .../PACKAGE.md | 32 +++++++ + .../PublicAPI/PublicAPI.Shipped.txt | 1 + + .../PublicAPI/PublicAPI.Unshipped.txt | 5 ++ + .../Resources/GitHubActionsResources.resx | 82 +++++++++++++++++ + .../xlf/GitHubActionsResources.cs.xlf | 37 ++++++++ + .../xlf/GitHubActionsResources.de.xlf | 37 ++++++++ + .../xlf/GitHubActionsResources.es.xlf | 37 ++++++++ + .../xlf/GitHubActionsResources.fr.xlf | 37 ++++++++ + .../xlf/GitHubActionsResources.it.xlf | 37 ++++++++ + .../xlf/GitHubActionsResources.ja.xlf | 37 ++++++++ + .../xlf/GitHubActionsResources.ko.xlf | 37 ++++++++ + .../xlf/GitHubActionsResources.pl.xlf | 37 ++++++++ + .../xlf/GitHubActionsResources.pt-BR.xlf | 37 ++++++++ + .../xlf/GitHubActionsResources.ru.xlf | 37 ++++++++ + .../xlf/GitHubActionsResources.tr.xlf | 37 ++++++++ + .../xlf/GitHubActionsResources.zh-Hans.xlf | 37 ++++++++ + .../xlf/GitHubActionsResources.zh-Hant.xlf | 37 ++++++++ + .../TargetFrameworkMonikerHelper.cs | 14 +++ + .../TestingPlatformBuilderHook.cs | 20 +++++ + ...sting.Extensions.GitHubActionsReport.props | 3 + + ...sting.Extensions.GitHubActionsReport.props | 9 ++ + ...sting.Extensions.GitHubActionsReport.props | 3 + + .../Microsoft.Testing.Platform.csproj | 1 + + .../TestFramework.Extensions.csproj | 76 ++++++++++++++-- + .../GitHubActionsCommandLineProviderTests.cs | 32 +++++++ + .../GitHubActionsEscaperTests.cs | 22 +++++ + .../GitHubActionsReporterTests.cs | 86 ++++++++++++++++++ + ...rosoft.Testing.Extensions.UnitTests.csproj | 3 + + 36 files changed, 1142 insertions(+), 7 deletions(-) + create mode 100644 src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/BannedSymbols.txt + create mode 100644 src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsCommandLineOptions.cs + create mode 100644 src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsCommandLineProvider.cs + create mode 100644 src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsEscaper.cs + create mode 100644 src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsExtensions.cs + create mode 100644 src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsReporter.cs + create mode 100644 src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Microsoft.Testing.Extensions.GitHubActionsReport.csproj + create mode 100644 src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/PACKAGE.md + create mode 100644 src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/PublicAPI/PublicAPI.Shipped.txt + create mode 100644 src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/PublicAPI/PublicAPI.Unshipped.txt + create mode 100644 src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/GitHubActionsResources.resx + create mode 100644 src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.cs.xlf + create mode 100644 src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.de.xlf + create mode 100644 src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.es.xlf + create mode 100644 src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.fr.xlf + create mode 100644 src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.it.xlf + create mode 100644 src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ja.xlf + create mode 100644 src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ko.xlf + create mode 100644 src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.pl.xlf + create mode 100644 src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.pt-BR.xlf + create mode 100644 src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ru.xlf + create mode 100644 src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.tr.xlf + create mode 100644 src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.zh-Hans.xlf + create mode 100644 src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.zh-Hant.xlf + create mode 100644 src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/TargetFrameworkMonikerHelper.cs + create mode 100644 src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/TestingPlatformBuilderHook.cs + create mode 100644 src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/build/Microsoft.Testing.Extensions.GitHubActionsReport.props + create mode 100644 src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/buildMultiTargeting/Microsoft.Testing.Extensions.GitHubActionsReport.props + create mode 100644 src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/buildTransitive/Microsoft.Testing.Extensions.GitHubActionsReport.props + create mode 100644 test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsCommandLineProviderTests.cs + create mode 100644 test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsEscaperTests.cs + create mode 100644 test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsReporterTests.cs + +diff --git a/TestFx.slnx b/TestFx.slnx +index 09a883489c..d962b1b3df 100644 +--- a/TestFx.slnx ++++ b/TestFx.slnx +@@ -38,6 +38,7 @@ + + + ++ + + + +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/BannedSymbols.txt b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/BannedSymbols.txt +new file mode 100644 +index 0000000000..64ef236c50 +--- /dev/null ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/BannedSymbols.txt +@@ -0,0 +1,9 @@ ++P:System.DateTime.Now; Use 'IClock' instead ++P:System.DateTime.UtcNow; Use 'IClock' instead ++M:System.Threading.Tasks.Task.Run(System.Action); Use 'ITask' instead ++M:System.Threading.Tasks.Task.WhenAll(System.Threading.Tasks.Task[]); Use 'ITask' instead ++M:System.Threading.Tasks.Task.WhenAll(System.Collections.Generic.IEnumerable{System.Threading.Tasks.Task}); Use 'ITask' instead ++M:System.String.IsNullOrEmpty(System.String); Use 'RoslynString.IsNullOrEmpty' instead ++M:System.String.IsNullOrWhiteSpace(System.String); Use 'RoslynString.IsNullOrWhiteSpace' instead ++M:System.Diagnostics.Debug.Assert(System.Boolean); Use 'RoslynDebug.Assert' instead ++M:System.Diagnostics.Debug.Assert(System.Boolean,System.String); Use 'RoslynDebug.Assert' instead +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsCommandLineOptions.cs b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsCommandLineOptions.cs +new file mode 100644 +index 0000000000..5a21b2acf4 +--- /dev/null ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsCommandLineOptions.cs +@@ -0,0 +1,12 @@ ++// Copyright (c) Microsoft Corporation. All rights reserved. ++// Licensed under the MIT license. See LICENSE file in the project root for full license information. ++ ++namespace Microsoft.Testing.Extensions.GitHubActionsReport; ++ ++internal static class GitHubActionsCommandLineOptions ++{ ++ public const string GitHubActionsOptionName = "report-gh"; ++ public const string GitHubActionsGroups = "report-gh-groups"; ++ public const string OptionOn = "on"; ++ public const string OptionOff = "off"; ++} +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsCommandLineProvider.cs b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsCommandLineProvider.cs +new file mode 100644 +index 0000000000..27ce5e5540 +--- /dev/null ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsCommandLineProvider.cs +@@ -0,0 +1,35 @@ ++// Copyright (c) Microsoft Corporation. All rights reserved. ++// Licensed under the MIT license. See LICENSE file in the project root for full license information. ++ ++using Microsoft.Testing.Extensions.GitHubActionsReport.Resources; ++using Microsoft.Testing.Platform.CommandLine; ++using Microsoft.Testing.Platform.Extensions; ++using Microsoft.Testing.Platform.Extensions.CommandLine; ++ ++namespace Microsoft.Testing.Extensions.GitHubActionsReport; ++ ++internal sealed class GitHubActionsCommandLineProvider : CommandLineOptionsProviderBase ++{ ++ private static readonly string[] OnOffOptions = [GitHubActionsCommandLineOptions.OptionOn, GitHubActionsCommandLineOptions.OptionOff]; ++ ++ public GitHubActionsCommandLineProvider() ++ : base( ++ nameof(GitHubActionsCommandLineProvider), ++ ExtensionVersion.DefaultSemVer, ++ GitHubActionsResources.DisplayName, ++ GitHubActionsResources.Description, ++ [ ++ new CommandLineOption(GitHubActionsCommandLineOptions.GitHubActionsGroups, GitHubActionsResources.GroupsOptionDescription, ArgumentArity.ExactlyOne, false), ++ new CommandLineOption(GitHubActionsCommandLineOptions.GitHubActionsOptionName, GitHubActionsResources.OptionDescription, ArgumentArity.Zero, false), ++ ]) ++ { ++ } ++ ++ public override Task ValidateOptionArgumentsAsync(CommandLineOption commandOption, string[] arguments) ++ => commandOption.Name switch ++ { ++ GitHubActionsCommandLineOptions.GitHubActionsGroups when !OnOffOptions.Contains(arguments[0], StringComparer.OrdinalIgnoreCase) ++ => ValidationResult.InvalidTask(string.Format(CultureInfo.InvariantCulture, GitHubActionsResources.InvalidOnOffValue, arguments[0])), ++ _ => ValidationResult.ValidTask, ++ }; ++} +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsEscaper.cs b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsEscaper.cs +new file mode 100644 +index 0000000000..f81bde3717 +--- /dev/null ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsEscaper.cs +@@ -0,0 +1,43 @@ ++// Copyright (c) Microsoft Corporation. All rights reserved. ++// Licensed under the MIT license. See LICENSE file in the project root for full license information. ++ ++using Microsoft.Testing.Platform; ++ ++namespace Microsoft.Testing.Extensions.GitHubActionsReport; ++ ++internal static class GitHubActionsEscaper ++{ ++ /// ++ /// Escapes a value used as the data portion of a GitHub Actions workflow command (the text after the ++ /// double-colon). See . ++ /// ++ public static string EscapeData(string value) ++ { ++ if (RoslynString.IsNullOrEmpty(value)) ++ { ++ return value; ++ } ++ ++ var result = new StringBuilder(value.Length); ++ foreach (char c in value) ++ { ++ switch (c) ++ { ++ case '%': ++ result.Append("%25"); ++ break; ++ case '\r': ++ result.Append("%0D"); ++ break; ++ case '\n': ++ result.Append("%0A"); ++ break; ++ default: ++ result.Append(c); ++ break; ++ } ++ } ++ ++ return result.ToString(); ++ } ++} +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsExtensions.cs b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsExtensions.cs +new file mode 100644 +index 0000000000..c59deb5ffc +--- /dev/null ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsExtensions.cs +@@ -0,0 +1,33 @@ ++// Copyright (c) Microsoft Corporation. All rights reserved. ++// Licensed under the MIT license. See LICENSE file in the project root for full license information. ++ ++using Microsoft.Testing.Extensions.GitHubActionsReport; ++using Microsoft.Testing.Platform.Builder; ++using Microsoft.Testing.Platform.Extensions; ++using Microsoft.Testing.Platform.Services; ++ ++namespace Microsoft.Testing.Extensions; ++ ++/// ++/// Provides extension methods for adding GitHub Actions reporting support to the test application builder. ++/// ++public static class GitHubActionsExtensions ++{ ++ /// ++ /// Adds support to the test application builder. ++ /// ++ /// The test application builder. ++ public static void AddGitHubActionsProvider(this ITestApplicationBuilder builder) ++ { ++ var compositeReporter = new CompositeExtensionFactory(serviceProvider => ++ new GitHubActionsReporter( ++ serviceProvider.GetCommandLineOptions(), ++ serviceProvider.GetEnvironment(), ++ serviceProvider.GetOutputDevice(), ++ serviceProvider.GetTestApplicationModuleInfo(), ++ serviceProvider.GetLoggerFactory())); ++ ++ builder.TestHost.AddTestSessionLifetimeHandler(compositeReporter); ++ builder.CommandLine.AddProvider(() => new GitHubActionsCommandLineProvider()); ++ } ++} +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsReporter.cs b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsReporter.cs +new file mode 100644 +index 0000000000..811441203c +--- /dev/null ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsReporter.cs +@@ -0,0 +1,87 @@ ++// Copyright (c) Microsoft Corporation. All rights reserved. ++// Licensed under the MIT license. See LICENSE file in the project root for full license information. ++ ++using Microsoft.Testing.Extensions.GitHubActionsReport.Resources; ++using Microsoft.Testing.Platform.CommandLine; ++using Microsoft.Testing.Platform.Extensions.OutputDevice; ++using Microsoft.Testing.Platform.Extensions.TestHost; ++using Microsoft.Testing.Platform.Helpers; ++using Microsoft.Testing.Platform.Logging; ++using Microsoft.Testing.Platform.OutputDevice; ++using Microsoft.Testing.Platform.Services; ++ ++namespace Microsoft.Testing.Extensions.GitHubActionsReport; ++ ++internal sealed class GitHubActionsReporter : ++ ITestSessionLifetimeHandler, ++ IOutputDeviceDataProducer ++{ ++ private readonly ICommandLineOptions _commandLine; ++ private readonly IEnvironment _environment; ++ private readonly IOutputDevice _outputDisplay; ++ private readonly ITestApplicationModuleInfo _testApplicationModuleInfo; ++ private readonly ILogger _logger; ++ private readonly string _targetFrameworkMoniker; ++ ++ public GitHubActionsReporter( ++ ICommandLineOptions commandLine, ++ IEnvironment environment, ++ IOutputDevice outputDisplay, ++ ITestApplicationModuleInfo testApplicationModuleInfo, ++ ILoggerFactory loggerFactory) ++ { ++ _commandLine = commandLine; ++ _environment = environment; ++ _outputDisplay = outputDisplay; ++ _testApplicationModuleInfo = testApplicationModuleInfo; ++ _logger = loggerFactory.CreateLogger(); ++ _targetFrameworkMoniker = TargetFrameworkMonikerHelper.GetTargetFrameworkMoniker(); ++ } ++ ++ /// ++ public string Uid => nameof(GitHubActionsReporter); ++ ++ /// ++ public string Version => ExtensionVersion.DefaultSemVer; ++ ++ /// ++ public string DisplayName { get; } = GitHubActionsResources.DisplayName; ++ ++ /// ++ public string Description { get; } = GitHubActionsResources.Description; ++ ++ /// ++ public Task IsEnabledAsync() ++ { ++ // The groups feature is on by default. It can be turned off explicitly via '--report-gh-groups off'. ++ if (_commandLine.TryGetOptionArgumentList(GitHubActionsCommandLineOptions.GitHubActionsGroups, out string[]? groupsArguments) ++ && groupsArguments is [string value] ++ && string.Equals(value, GitHubActionsCommandLineOptions.OptionOff, StringComparison.OrdinalIgnoreCase)) ++ { ++ return Task.FromResult(false); ++ } ++ ++ // Auto-activate when running on GitHub Actions, but only no-op otherwise. ++ bool isGitHubActions = string.Equals(_environment.GetEnvironmentVariable("GITHUB_ACTIONS"), "true", StringComparison.OrdinalIgnoreCase); ++ bool isEnabled = isGitHubActions || _commandLine.IsOptionSet(GitHubActionsCommandLineOptions.GitHubActionsOptionName); ++ ++ if (_logger.IsEnabled(LogLevel.Trace)) ++ { ++ _logger.LogTrace($"{nameof(GitHubActionsReport)} is {(isEnabled ? "enabled" : "disabled")}."); ++ } ++ ++ return Task.FromResult(isEnabled); ++ } ++ ++ /// ++ public async Task OnTestSessionStartingAsync(ITestSessionContext testSessionContext) ++ { ++ string name = _testApplicationModuleInfo.TryGetAssemblyName() ?? _testApplicationModuleInfo.GetDisplayName(); ++ string title = string.Format(CultureInfo.InvariantCulture, GitHubActionsResources.GroupTitle, name, _targetFrameworkMoniker); ++ await _outputDisplay.DisplayAsync(this, new FormattedTextOutputDeviceData($"::group::{GitHubActionsEscaper.EscapeData(title)}"), testSessionContext.CancellationToken).ConfigureAwait(false); ++ } ++ ++ /// ++ public async Task OnTestSessionFinishingAsync(ITestSessionContext testSessionContext) ++ => await _outputDisplay.DisplayAsync(this, new FormattedTextOutputDeviceData("::endgroup::"), testSessionContext.CancellationToken).ConfigureAwait(false); ++} +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Microsoft.Testing.Extensions.GitHubActionsReport.csproj b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Microsoft.Testing.Extensions.GitHubActionsReport.csproj +new file mode 100644 +index 0000000000..c0334fad2a +--- /dev/null ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Microsoft.Testing.Extensions.GitHubActionsReport.csproj +@@ -0,0 +1,59 @@ ++ ++ ++ ++ netstandard2.0;$(SupportedNetFrameworks) ++ true ++ $(RepoRoot)src\Platform\SharedExtensionHelpers\BuildInfo.cs.template ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ true ++ buildMultiTargeting ++ ++ ++ buildTransitive/$(TargetFramework) ++ ++ ++ build/$(TargetFramework) ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/PACKAGE.md b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/PACKAGE.md +new file mode 100644 +index 0000000000..dda3f30429 +--- /dev/null ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/PACKAGE.md +@@ -0,0 +1,32 @@ ++# Microsoft.Testing.Extensions.GitHubActionsReport ++ ++Microsoft.Testing.Extensions.GitHubActionsReport is an extension for [Microsoft.Testing.Platform](https://www.nuget.org/packages/Microsoft.Testing.Platform) that emits GitHub Actions-native workflow commands so test runs on GitHub Actions produce a first-class experience. ++ ++Microsoft.Testing.Platform is open source. You can find `Microsoft.Testing.Extensions.GitHubActionsReport` code in the [microsoft/testfx](https://github.com/microsoft/testfx) GitHub repository. ++ ++## Install the package ++ ++```dotnetcli ++dotnet add package Microsoft.Testing.Extensions.GitHubActionsReport ++``` ++ ++## About ++ ++This package extends Microsoft.Testing.Platform with: ++ ++- **Per-assembly log groups**: emits `::group::` / `::endgroup::` workflow commands so each test assembly's output is collapsed by default in the runner UI ++- **CI auto-detection**: detects GitHub Actions environments through the `GITHUB_ACTIONS` variable ++ ++Log groups are enabled by default when running on GitHub Actions. Turn them off with `--report-gh-groups off`. ++ ++## Related packages ++ ++- [Microsoft.Testing.Extensions.AzureDevOpsReport](https://www.nuget.org/packages/Microsoft.Testing.Extensions.AzureDevOpsReport): Azure DevOps reporting ++ ++## Documentation ++ ++For comprehensive documentation, see . ++ ++## Feedback & contributing ++ ++Microsoft.Testing.Platform is an open source project. Provide feedback or report issues in the [microsoft/testfx](https://github.com/microsoft/testfx/issues) GitHub repository. +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/PublicAPI/PublicAPI.Shipped.txt b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/PublicAPI/PublicAPI.Shipped.txt +new file mode 100644 +index 0000000000..7dc5c58110 +--- /dev/null ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/PublicAPI/PublicAPI.Shipped.txt +@@ -0,0 +1 @@ ++#nullable enable +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/PublicAPI/PublicAPI.Unshipped.txt b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/PublicAPI/PublicAPI.Unshipped.txt +new file mode 100644 +index 0000000000..0a19cd14d6 +--- /dev/null ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/PublicAPI/PublicAPI.Unshipped.txt +@@ -0,0 +1,5 @@ ++#nullable enable ++Microsoft.Testing.Extensions.GitHubActionsExtensions ++Microsoft.Testing.Extensions.GitHubActionsReport.TestingPlatformBuilderHook ++static Microsoft.Testing.Extensions.GitHubActionsExtensions.AddGitHubActionsProvider(this Microsoft.Testing.Platform.Builder.ITestApplicationBuilder! builder) -> void ++static Microsoft.Testing.Extensions.GitHubActionsReport.TestingPlatformBuilderHook.AddExtensions(Microsoft.Testing.Platform.Builder.ITestApplicationBuilder! testApplicationBuilder, string![]! _) -> void +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/GitHubActionsResources.resx b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/GitHubActionsResources.resx +new file mode 100644 +index 0000000000..b37a3b8e43 +--- /dev/null ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/GitHubActionsResources.resx +@@ -0,0 +1,82 @@ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ text/microsoft-resx ++ ++ ++ 2.0 ++ ++ ++ System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 ++ ++ ++ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 ++ ++ ++ GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. ++ ++ ++ GitHub Actions report generator ++ ++ ++ Tests: {0} ({1}) ++ {0} is the assembly name, {1} is the target framework moniker. ++ ++ ++ Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"} ++ ++ ++ Invalid value '{0}'. Valid values are 'on' and 'off'. ++ {Locked="on"}{Locked="off"} ++ ++ ++ Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. ++ ++ +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.cs.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.cs.xlf +new file mode 100644 +index 0000000000..81eddc21b1 +--- /dev/null ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.cs.xlf +@@ -0,0 +1,37 @@ ++ ++ ++ ++ ++ ++ GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. ++ GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. ++ ++ ++ ++ GitHub Actions report generator ++ GitHub Actions report generator ++ ++ ++ ++ Tests: {0} ({1}) ++ Tests: {0} ({1}) ++ {0} is the assembly name, {1} is the target framework moniker. ++ ++ ++ Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"} ++ ++ ++ Invalid value '{0}'. Valid values are 'on' and 'off'. ++ Invalid value '{0}'. Valid values are 'on' and 'off'. ++ {Locked="on"}{Locked="off"} ++ ++ ++ Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. ++ Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. ++ ++ ++ ++ ++ +\ No newline at end of file +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.de.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.de.xlf +new file mode 100644 +index 0000000000..038d961cfc +--- /dev/null ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.de.xlf +@@ -0,0 +1,37 @@ ++ ++ ++ ++ ++ ++ GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. ++ GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. ++ ++ ++ ++ GitHub Actions report generator ++ GitHub Actions report generator ++ ++ ++ ++ Tests: {0} ({1}) ++ Tests: {0} ({1}) ++ {0} is the assembly name, {1} is the target framework moniker. ++ ++ ++ Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"} ++ ++ ++ Invalid value '{0}'. Valid values are 'on' and 'off'. ++ Invalid value '{0}'. Valid values are 'on' and 'off'. ++ {Locked="on"}{Locked="off"} ++ ++ ++ Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. ++ Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. ++ ++ ++ ++ ++ +\ No newline at end of file +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.es.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.es.xlf +new file mode 100644 +index 0000000000..160e36f516 +--- /dev/null ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.es.xlf +@@ -0,0 +1,37 @@ ++ ++ ++ ++ ++ ++ GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. ++ GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. ++ ++ ++ ++ GitHub Actions report generator ++ GitHub Actions report generator ++ ++ ++ ++ Tests: {0} ({1}) ++ Tests: {0} ({1}) ++ {0} is the assembly name, {1} is the target framework moniker. ++ ++ ++ Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"} ++ ++ ++ Invalid value '{0}'. Valid values are 'on' and 'off'. ++ Invalid value '{0}'. Valid values are 'on' and 'off'. ++ {Locked="on"}{Locked="off"} ++ ++ ++ Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. ++ Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. ++ ++ ++ ++ ++ +\ No newline at end of file +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.fr.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.fr.xlf +new file mode 100644 +index 0000000000..8b091aee4f +--- /dev/null ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.fr.xlf +@@ -0,0 +1,37 @@ ++ ++ ++ ++ ++ ++ GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. ++ GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. ++ ++ ++ ++ GitHub Actions report generator ++ GitHub Actions report generator ++ ++ ++ ++ Tests: {0} ({1}) ++ Tests: {0} ({1}) ++ {0} is the assembly name, {1} is the target framework moniker. ++ ++ ++ Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"} ++ ++ ++ Invalid value '{0}'. Valid values are 'on' and 'off'. ++ Invalid value '{0}'. Valid values are 'on' and 'off'. ++ {Locked="on"}{Locked="off"} ++ ++ ++ Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. ++ Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. ++ ++ ++ ++ ++ +\ No newline at end of file +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.it.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.it.xlf +new file mode 100644 +index 0000000000..66e1007b08 +--- /dev/null ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.it.xlf +@@ -0,0 +1,37 @@ ++ ++ ++ ++ ++ ++ GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. ++ GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. ++ ++ ++ ++ GitHub Actions report generator ++ GitHub Actions report generator ++ ++ ++ ++ Tests: {0} ({1}) ++ Tests: {0} ({1}) ++ {0} is the assembly name, {1} is the target framework moniker. ++ ++ ++ Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"} ++ ++ ++ Invalid value '{0}'. Valid values are 'on' and 'off'. ++ Invalid value '{0}'. Valid values are 'on' and 'off'. ++ {Locked="on"}{Locked="off"} ++ ++ ++ Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. ++ Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. ++ ++ ++ ++ ++ +\ No newline at end of file +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ja.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ja.xlf +new file mode 100644 +index 0000000000..e5c553b74d +--- /dev/null ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ja.xlf +@@ -0,0 +1,37 @@ ++ ++ ++ ++ ++ ++ GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. ++ GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. ++ ++ ++ ++ GitHub Actions report generator ++ GitHub Actions report generator ++ ++ ++ ++ Tests: {0} ({1}) ++ Tests: {0} ({1}) ++ {0} is the assembly name, {1} is the target framework moniker. ++ ++ ++ Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"} ++ ++ ++ Invalid value '{0}'. Valid values are 'on' and 'off'. ++ Invalid value '{0}'. Valid values are 'on' and 'off'. ++ {Locked="on"}{Locked="off"} ++ ++ ++ Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. ++ Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. ++ ++ ++ ++ ++ +\ No newline at end of file +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ko.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ko.xlf +new file mode 100644 +index 0000000000..60d6a9ca54 +--- /dev/null ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ko.xlf +@@ -0,0 +1,37 @@ ++ ++ ++ ++ ++ ++ GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. ++ GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. ++ ++ ++ ++ GitHub Actions report generator ++ GitHub Actions report generator ++ ++ ++ ++ Tests: {0} ({1}) ++ Tests: {0} ({1}) ++ {0} is the assembly name, {1} is the target framework moniker. ++ ++ ++ Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"} ++ ++ ++ Invalid value '{0}'. Valid values are 'on' and 'off'. ++ Invalid value '{0}'. Valid values are 'on' and 'off'. ++ {Locked="on"}{Locked="off"} ++ ++ ++ Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. ++ Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. ++ ++ ++ ++ ++ +\ No newline at end of file +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.pl.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.pl.xlf +new file mode 100644 +index 0000000000..5eb8b2054d +--- /dev/null ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.pl.xlf +@@ -0,0 +1,37 @@ ++ ++ ++ ++ ++ ++ GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. ++ GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. ++ ++ ++ ++ GitHub Actions report generator ++ GitHub Actions report generator ++ ++ ++ ++ Tests: {0} ({1}) ++ Tests: {0} ({1}) ++ {0} is the assembly name, {1} is the target framework moniker. ++ ++ ++ Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"} ++ ++ ++ Invalid value '{0}'. Valid values are 'on' and 'off'. ++ Invalid value '{0}'. Valid values are 'on' and 'off'. ++ {Locked="on"}{Locked="off"} ++ ++ ++ Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. ++ Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. ++ ++ ++ ++ ++ +\ No newline at end of file +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.pt-BR.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.pt-BR.xlf +new file mode 100644 +index 0000000000..fb5fd10afe +--- /dev/null ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.pt-BR.xlf +@@ -0,0 +1,37 @@ ++ ++ ++ ++ ++ ++ GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. ++ GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. ++ ++ ++ ++ GitHub Actions report generator ++ GitHub Actions report generator ++ ++ ++ ++ Tests: {0} ({1}) ++ Tests: {0} ({1}) ++ {0} is the assembly name, {1} is the target framework moniker. ++ ++ ++ Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"} ++ ++ ++ Invalid value '{0}'. Valid values are 'on' and 'off'. ++ Invalid value '{0}'. Valid values are 'on' and 'off'. ++ {Locked="on"}{Locked="off"} ++ ++ ++ Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. ++ Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. ++ ++ ++ ++ ++ +\ No newline at end of file +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ru.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ru.xlf +new file mode 100644 +index 0000000000..77613b3487 +--- /dev/null ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ru.xlf +@@ -0,0 +1,37 @@ ++ ++ ++ ++ ++ ++ GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. ++ GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. ++ ++ ++ ++ GitHub Actions report generator ++ GitHub Actions report generator ++ ++ ++ ++ Tests: {0} ({1}) ++ Tests: {0} ({1}) ++ {0} is the assembly name, {1} is the target framework moniker. ++ ++ ++ Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"} ++ ++ ++ Invalid value '{0}'. Valid values are 'on' and 'off'. ++ Invalid value '{0}'. Valid values are 'on' and 'off'. ++ {Locked="on"}{Locked="off"} ++ ++ ++ Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. ++ Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. ++ ++ ++ ++ ++ +\ No newline at end of file +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.tr.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.tr.xlf +new file mode 100644 +index 0000000000..2311aaeca6 +--- /dev/null ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.tr.xlf +@@ -0,0 +1,37 @@ ++ ++ ++ ++ ++ ++ GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. ++ GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. ++ ++ ++ ++ GitHub Actions report generator ++ GitHub Actions report generator ++ ++ ++ ++ Tests: {0} ({1}) ++ Tests: {0} ({1}) ++ {0} is the assembly name, {1} is the target framework moniker. ++ ++ ++ Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"} ++ ++ ++ Invalid value '{0}'. Valid values are 'on' and 'off'. ++ Invalid value '{0}'. Valid values are 'on' and 'off'. ++ {Locked="on"}{Locked="off"} ++ ++ ++ Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. ++ Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. ++ ++ ++ ++ ++ +\ No newline at end of file +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.zh-Hans.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.zh-Hans.xlf +new file mode 100644 +index 0000000000..a908b510c2 +--- /dev/null ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.zh-Hans.xlf +@@ -0,0 +1,37 @@ ++ ++ ++ ++ ++ ++ GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. ++ GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. ++ ++ ++ ++ GitHub Actions report generator ++ GitHub Actions report generator ++ ++ ++ ++ Tests: {0} ({1}) ++ Tests: {0} ({1}) ++ {0} is the assembly name, {1} is the target framework moniker. ++ ++ ++ Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"} ++ ++ ++ Invalid value '{0}'. Valid values are 'on' and 'off'. ++ Invalid value '{0}'. Valid values are 'on' and 'off'. ++ {Locked="on"}{Locked="off"} ++ ++ ++ Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. ++ Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. ++ ++ ++ ++ ++ +\ No newline at end of file +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.zh-Hant.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.zh-Hant.xlf +new file mode 100644 +index 0000000000..d20b0a8ebe +--- /dev/null ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.zh-Hant.xlf +@@ -0,0 +1,37 @@ ++ ++ ++ ++ ++ ++ GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. ++ GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. ++ ++ ++ ++ GitHub Actions report generator ++ GitHub Actions report generator ++ ++ ++ ++ Tests: {0} ({1}) ++ Tests: {0} ({1}) ++ {0} is the assembly name, {1} is the target framework moniker. ++ ++ ++ Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"} ++ ++ ++ Invalid value '{0}'. Valid values are 'on' and 'off'. ++ Invalid value '{0}'. Valid values are 'on' and 'off'. ++ {Locked="on"}{Locked="off"} ++ ++ ++ Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. ++ Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. ++ ++ ++ ++ ++ +\ No newline at end of file +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/TargetFrameworkMonikerHelper.cs b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/TargetFrameworkMonikerHelper.cs +new file mode 100644 +index 0000000000..1de5dbc640 +--- /dev/null ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/TargetFrameworkMonikerHelper.cs +@@ -0,0 +1,14 @@ ++// Copyright (c) Microsoft Corporation. All rights reserved. ++// Licensed under the MIT license. See LICENSE file in the project root for full license information. ++ ++using Microsoft.Testing.Platform.OutputDevice; ++ ++namespace Microsoft.Testing.Extensions.GitHubActionsReport; ++ ++internal static class TargetFrameworkMonikerHelper ++{ ++ public static string GetTargetFrameworkMoniker() ++ => TargetFrameworkParser.GetShortTargetFramework(Assembly.GetEntryAssembly()?.GetCustomAttribute()?.FrameworkDisplayName) ++ ?? TargetFrameworkParser.GetShortTargetFramework(System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription) ++ ?? "unknown"; ++} +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/TestingPlatformBuilderHook.cs b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/TestingPlatformBuilderHook.cs +new file mode 100644 +index 0000000000..849231f52a +--- /dev/null ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/TestingPlatformBuilderHook.cs +@@ -0,0 +1,20 @@ ++// Copyright (c) Microsoft Corporation. All rights reserved. ++// Licensed under the MIT license. See LICENSE file in the project root for full license information. ++ ++using Microsoft.Testing.Platform.Builder; ++ ++namespace Microsoft.Testing.Extensions.GitHubActionsReport; ++ ++/// ++/// This class is used by Microsoft.Testing.Platform.MSBuild to hook into the Testing Platform Builder to add GitHub Actions reporting support. ++/// ++public static class TestingPlatformBuilderHook ++{ ++ /// ++ /// Adds GitHub Actions reporting support to the Testing Platform Builder. ++ /// ++ /// The test application builder. ++ /// The command line arguments. ++ public static void AddExtensions(ITestApplicationBuilder testApplicationBuilder, string[] _) ++ => testApplicationBuilder.AddGitHubActionsProvider(); ++} +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/build/Microsoft.Testing.Extensions.GitHubActionsReport.props b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/build/Microsoft.Testing.Extensions.GitHubActionsReport.props +new file mode 100644 +index 0000000000..b640aa2cd6 +--- /dev/null ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/build/Microsoft.Testing.Extensions.GitHubActionsReport.props +@@ -0,0 +1,3 @@ ++ ++ ++ +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/buildMultiTargeting/Microsoft.Testing.Extensions.GitHubActionsReport.props b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/buildMultiTargeting/Microsoft.Testing.Extensions.GitHubActionsReport.props +new file mode 100644 +index 0000000000..ea74c713e0 +--- /dev/null ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/buildMultiTargeting/Microsoft.Testing.Extensions.GitHubActionsReport.props +@@ -0,0 +1,9 @@ ++ ++ ++ ++ ++ Microsoft.Testing.Extensions.GitHubActionsReport ++ Microsoft.Testing.Extensions.GitHubActionsReport.TestingPlatformBuilderHook ++ ++ ++ +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/buildTransitive/Microsoft.Testing.Extensions.GitHubActionsReport.props b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/buildTransitive/Microsoft.Testing.Extensions.GitHubActionsReport.props +new file mode 100644 +index 0000000000..b640aa2cd6 +--- /dev/null ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/buildTransitive/Microsoft.Testing.Extensions.GitHubActionsReport.props +@@ -0,0 +1,3 @@ ++ ++ ++ +diff --git a/src/Platform/Microsoft.Testing.Platform/Microsoft.Testing.Platform.csproj b/src/Platform/Microsoft.Testing.Platform/Microsoft.Testing.Platform.csproj +index 11272e13ce..eb6b45956b 100644 +--- a/src/Platform/Microsoft.Testing.Platform/Microsoft.Testing.Platform.csproj ++++ b/src/Platform/Microsoft.Testing.Platform/Microsoft.Testing.Platform.csproj +@@ -41,6 +41,7 @@ This package provides the core platform and the .NET implementation of the proto + + + ++ + + + +diff --git a/src/TestFramework/TestFramework.Extensions/TestFramework.Extensions.csproj b/src/TestFramework/TestFramework.Extensions/TestFramework.Extensions.csproj +index f64faadedb..a6ed9e60d8 100644 +--- a/src/TestFramework/TestFramework.Extensions/TestFramework.Extensions.csproj ++++ b/src/TestFramework/TestFramework.Extensions/TestFramework.Extensions.csproj +@@ -1,4 +1,4 @@ +- ++ + + + +@@ -11,9 +11,6 @@ + + + true +- MSTest.TestFramework.nuspec +- MSTest.TestFramework.NonWindows.nuspec +- $(OutputPath) + MSTest.TestFramework + MSTest TestFramework Unittest MSTestV2 Microsoft Test Testing TDD Framework + +@@ -28,7 +25,10 @@ + - UWP 10.0.16299 + - UWP 10.0.17763 with .NET 9+ + ++ PACKAGE.md + $(NoWarn);NU5100 ++ ++ false + + + +@@ -61,6 +61,22 @@ + + + ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ + + + +@@ -79,9 +95,55 @@ + + + +- +- +- ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ + + + +diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsCommandLineProviderTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsCommandLineProviderTests.cs +new file mode 100644 +index 0000000000..f25ef4a930 +--- /dev/null ++++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsCommandLineProviderTests.cs +@@ -0,0 +1,32 @@ ++// Copyright (c) Microsoft Corporation. All rights reserved. ++// Licensed under the MIT license. See LICENSE file in the project root for full license information. ++ ++using Microsoft.Testing.Extensions.GitHubActionsReport; ++using Microsoft.Testing.Platform.CommandLine; ++using Microsoft.Testing.Platform.Extensions.CommandLine; ++ ++namespace Microsoft.Testing.Extensions.UnitTests; ++ ++[TestClass] ++public sealed class GitHubActionsCommandLineProviderTests ++{ ++ [TestMethod] ++ public async Task ValidateOptionArgumentsAsync_ReturnsInvalid_WhenGroupsValueIsNotOnOrOffAsync() ++ { ++ GitHubActionsCommandLineProvider provider = new(); ++ CommandLineOption option = provider.GetCommandLineOptions().Single(o => o.Name == GitHubActionsCommandLineOptions.GitHubActionsGroups); ++ ValidationResult validationResult = await provider.ValidateOptionArgumentsAsync(option, ["maybe"]).ConfigureAwait(false); ++ ++ Assert.IsFalse(validationResult.IsValid); ++ } ++ ++ [TestMethod] ++ public async Task ValidateOptionArgumentsAsync_ReturnsValid_WhenGroupsValueIsOffAsync() ++ { ++ GitHubActionsCommandLineProvider provider = new(); ++ CommandLineOption option = provider.GetCommandLineOptions().Single(o => o.Name == GitHubActionsCommandLineOptions.GitHubActionsGroups); ++ ValidationResult validationResult = await provider.ValidateOptionArgumentsAsync(option, ["off"]).ConfigureAwait(false); ++ ++ Assert.IsTrue(validationResult.IsValid); ++ } ++} +diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsEscaperTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsEscaperTests.cs +new file mode 100644 +index 0000000000..d0ff29707a +--- /dev/null ++++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsEscaperTests.cs +@@ -0,0 +1,22 @@ ++// Copyright (c) Microsoft Corporation. All rights reserved. ++// Licensed under the MIT license. See LICENSE file in the project root for full license information. ++ ++using Microsoft.Testing.Extensions.GitHubActionsReport; ++ ++namespace Microsoft.Testing.Extensions.UnitTests; ++ ++[TestClass] ++public sealed class GitHubActionsEscaperTests ++{ ++ [TestMethod] ++ public void EscapeData_EscapesPercentAndNewlines() ++ => Assert.AreEqual("a%25b%0Ac%0Dd", GitHubActionsEscaper.EscapeData("a%b\nc\rd")); ++ ++ [TestMethod] ++ public void EscapeData_LeavesPlainTextUntouched() ++ => Assert.AreEqual("Tests: MSTest.UnitTests (net9.0)", GitHubActionsEscaper.EscapeData("Tests: MSTest.UnitTests (net9.0)")); ++ ++ [TestMethod] ++ public void EscapeData_ReturnsEmptyForEmpty() ++ => Assert.AreEqual(string.Empty, GitHubActionsEscaper.EscapeData(string.Empty)); ++} +diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsReporterTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsReporterTests.cs +new file mode 100644 +index 0000000000..52c79330c2 +--- /dev/null ++++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsReporterTests.cs +@@ -0,0 +1,86 @@ ++// Copyright (c) Microsoft Corporation. All rights reserved. ++// Licensed under the MIT license. See LICENSE file in the project root for full license information. ++ ++using Microsoft.Testing.Extensions.GitHubActionsReport; ++using Microsoft.Testing.Extensions.UnitTests.Helpers; ++using Microsoft.Testing.Platform.Extensions.OutputDevice; ++using Microsoft.Testing.Platform.Extensions.TestHost; ++using Microsoft.Testing.Platform.Helpers; ++using Microsoft.Testing.Platform.Logging; ++using Microsoft.Testing.Platform.OutputDevice; ++using Microsoft.Testing.Platform.Services; ++ ++using Moq; ++ ++namespace Microsoft.Testing.Extensions.UnitTests; ++ ++[TestClass] ++public sealed class GitHubActionsReporterTests ++{ ++ [TestMethod] ++ public async Task IsEnabledAsync_ReturnsTrue_WhenRunningOnGitHubActionsAsync() ++ { ++ GitHubActionsReporter reporter = CreateReporter(githubActions: true, options: []); ++ Assert.IsTrue(await reporter.IsEnabledAsync().ConfigureAwait(false)); ++ } ++ ++ [TestMethod] ++ public async Task IsEnabledAsync_ReturnsFalse_WhenNotOnGitHubActionsAsync() ++ { ++ GitHubActionsReporter reporter = CreateReporter(githubActions: false, options: []); ++ Assert.IsFalse(await reporter.IsEnabledAsync().ConfigureAwait(false)); ++ } ++ ++ [TestMethod] ++ public async Task IsEnabledAsync_ReturnsFalse_WhenGroupsExplicitlyOffAsync() ++ { ++ GitHubActionsReporter reporter = CreateReporter(githubActions: true, options: new Dictionary ++ { ++ [GitHubActionsCommandLineOptions.GitHubActionsGroups] = ["off"], ++ }); ++ Assert.IsFalse(await reporter.IsEnabledAsync().ConfigureAwait(false)); ++ } ++ ++ [TestMethod] ++ public async Task SessionLifetime_EmitsGroupAndEndGroupAsync() ++ { ++ List output = []; ++ GitHubActionsReporter reporter = CreateReporter(githubActions: true, options: [], output, assemblyName: "MSTest.UnitTests"); ++ ++ var context = new Mock(); ++ context.SetupGet(c => c.CancellationToken).Returns(CancellationToken.None); ++ ++ await reporter.OnTestSessionStartingAsync(context.Object).ConfigureAwait(false); ++ await reporter.OnTestSessionFinishingAsync(context.Object).ConfigureAwait(false); ++ ++ Assert.HasCount(2, output); ++ Assert.IsTrue(output[0].StartsWith("::group::Tests: MSTest.UnitTests", StringComparison.Ordinal), output[0]); ++ Assert.AreEqual("::endgroup::", output[1]); ++ } ++ ++ private static GitHubActionsReporter CreateReporter(bool githubActions, Dictionary options, List? output = null, string assemblyName = "Some.UnitTests") ++ { ++ var environment = new Mock(); ++ environment.Setup(e => e.GetEnvironmentVariable("GITHUB_ACTIONS")).Returns(githubActions ? "true" : null); ++ ++ var outputDevice = new Mock(); ++ outputDevice ++ .Setup(o => o.DisplayAsync(It.IsAny(), It.IsAny(), It.IsAny())) ++ .Callback((_, data, _) => output?.Add(((FormattedTextOutputDeviceData)data).Text)) ++ .Returns(Task.CompletedTask); ++ ++ var moduleInfo = new Mock(); ++ moduleInfo.Setup(m => m.TryGetAssemblyName()).Returns(assemblyName); ++ ++ var logger = new Mock(); ++ var loggerFactory = new Mock(); ++ loggerFactory.Setup(f => f.CreateLogger(It.IsAny())).Returns(logger.Object); ++ ++ return new GitHubActionsReporter( ++ new TestCommandLineOptions(options), ++ environment.Object, ++ outputDevice.Object, ++ moduleInfo.Object, ++ loggerFactory.Object); ++ } ++} +diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/Microsoft.Testing.Extensions.UnitTests.csproj b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/Microsoft.Testing.Extensions.UnitTests.csproj +index 401e5457c9..573b47e720 100644 +--- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/Microsoft.Testing.Extensions.UnitTests.csproj ++++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/Microsoft.Testing.Extensions.UnitTests.csproj +@@ -33,6 +33,9 @@ + + TargetFramework=netstandard2.0 + ++ ++ TargetFramework=netstandard2.0 ++ + + TargetFramework=netstandard2.0 + + +From fdd512e99a595c99ac7f55f164195fa13114b4f1 Mon Sep 17 00:00:00 2001 +From: Azat Mukhametshin +Date: Mon, 29 Jun 2026 20:30:40 +0200 +Subject: [PATCH 03/32] Add GitHub Actions failure annotations (#9142 + requirement 2) + +Emit a ::error workflow command for each failing test via a new +GitHubActionsAnnotationReporter (IDataConsumer), mirroring the +AzureDevOpsReport structure. Resolves the source file/line from the +exception stack trace relative to GITHUB_WORKSPACE (or the .git root), +skipping MSTest assertion-implementation frames, and falls back to a +title-only annotation when no location is available. + +Adds the --report-gh-annotations on|off knob (default on) and a shared +GitHubActionsFeature activation helper. Includes unit tests for the +annotation formatting/escaping and option validation. + +Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> +--- + .../GitHubActionsAnnotationReporter.cs | 246 ++++++++++++++++++ + .../GitHubActionsCommandLineOptions.cs | 5 + + .../GitHubActionsCommandLineProvider.cs | 4 +- + .../GitHubActionsEscaper.cs | 42 +++ + .../GitHubActionsExtensions.cs | 8 + + .../GitHubActionsFeature.cs | 31 +++ + .../GitHubActionsReporter.cs | 14 +- + .../GitHubActionsRepositoryRoot.cs | 58 +++++ + .../Resources/GitHubActionsResources.resx | 11 + + .../xlf/GitHubActionsResources.cs.xlf | 15 ++ + .../xlf/GitHubActionsResources.de.xlf | 15 ++ + .../xlf/GitHubActionsResources.es.xlf | 15 ++ + .../xlf/GitHubActionsResources.fr.xlf | 15 ++ + .../xlf/GitHubActionsResources.it.xlf | 15 ++ + .../xlf/GitHubActionsResources.ja.xlf | 15 ++ + .../xlf/GitHubActionsResources.ko.xlf | 15 ++ + .../xlf/GitHubActionsResources.pl.xlf | 15 ++ + .../xlf/GitHubActionsResources.pt-BR.xlf | 15 ++ + .../xlf/GitHubActionsResources.ru.xlf | 15 ++ + .../xlf/GitHubActionsResources.tr.xlf | 15 ++ + .../xlf/GitHubActionsResources.zh-Hans.xlf | 15 ++ + .../xlf/GitHubActionsResources.zh-Hant.xlf | 15 ++ + .../TestNodeIdentity.cs | 30 +++ + .../GitHubActionsAnnotationReporterTests.cs | 84 ++++++ + .../GitHubActionsCommandLineProviderTests.cs | 20 ++ + .../GitHubActionsEscaperTests.cs | 8 + + 26 files changed, 743 insertions(+), 13 deletions(-) + create mode 100644 src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsAnnotationReporter.cs + create mode 100644 src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsFeature.cs + create mode 100644 src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsRepositoryRoot.cs + create mode 100644 src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/TestNodeIdentity.cs + create mode 100644 test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsAnnotationReporterTests.cs + +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsAnnotationReporter.cs b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsAnnotationReporter.cs +new file mode 100644 +index 0000000000..d741edcdb1 +--- /dev/null ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsAnnotationReporter.cs +@@ -0,0 +1,246 @@ ++// Copyright (c) Microsoft Corporation. All rights reserved. ++// Licensed under the MIT license. See LICENSE file in the project root for full license information. ++ ++using Microsoft.Testing.Extensions.GitHubActionsReport.Resources; ++using Microsoft.Testing.Platform; ++using Microsoft.Testing.Platform.CommandLine; ++using Microsoft.Testing.Platform.Extensions; ++using Microsoft.Testing.Platform.Extensions.Messages; ++using Microsoft.Testing.Platform.Extensions.OutputDevice; ++using Microsoft.Testing.Platform.Helpers; ++using Microsoft.Testing.Platform.Logging; ++using Microsoft.Testing.Platform.OutputDevice; ++ ++namespace Microsoft.Testing.Extensions.GitHubActionsReport; ++ ++/// ++/// Emits a GitHub Actions ::error workflow command for each failing test so the failure surfaces ++/// both in the workflow run's Annotations tab and, when the source location can be resolved, on the ++/// pull request's "Files changed" diff gutter. ++/// ++internal sealed class GitHubActionsAnnotationReporter : ++ IDataConsumer, ++ IOutputDeviceDataProducer ++{ ++ private const string DeterministicBuildRoot = "/_/"; ++ private static readonly char[] NewlineCharacters = ['\r', '\n']; ++ ++ // Fully-qualified type prefixes for MSTest assertion implementations. A stack frame whose ++ // 'code' starts with any of these is treated as framework internals and skipped when looking ++ // for the user's call site to annotate. ++ private static readonly string[] AssertionImplementationCodePrefixes = ++ [ ++ "Microsoft.VisualStudio.TestTools.UnitTesting.Assert.", ++ "Microsoft.VisualStudio.TestTools.UnitTesting.AssertExtensions.", ++ "Microsoft.VisualStudio.TestTools.UnitTesting.CollectionAssert.", ++ "Microsoft.VisualStudio.TestTools.UnitTesting.StringAssert.", ++ ]; ++ ++ private readonly IEnvironment _environment; ++ private readonly IFileSystem _fileSystem; ++ private readonly IOutputDevice _outputDisplay; ++ private readonly ILogger _logger; ++ private readonly bool _isEnabled; ++ ++ public GitHubActionsAnnotationReporter( ++ ICommandLineOptions commandLine, ++ IEnvironment environment, ++ IFileSystem fileSystem, ++ IOutputDevice outputDisplay, ++ ILoggerFactory loggerFactory) ++ { ++ _environment = environment; ++ _fileSystem = fileSystem; ++ _outputDisplay = outputDisplay; ++ _logger = loggerFactory.CreateLogger(); ++ _isEnabled = GitHubActionsFeature.IsEnabled(commandLine, environment, GitHubActionsCommandLineOptions.GitHubActionsAnnotations); ++ } ++ ++ public Type[] DataTypesConsumed { get; } = [typeof(TestNodeUpdateMessage)]; ++ ++ /// ++ public string Uid => nameof(GitHubActionsAnnotationReporter); ++ ++ /// ++ public string Version => ExtensionVersion.DefaultSemVer; ++ ++ /// ++ public string DisplayName { get; } = GitHubActionsResources.DisplayName; ++ ++ /// ++ public string Description { get; } = GitHubActionsResources.Description; ++ ++ /// ++ public Task IsEnabledAsync() => Task.FromResult(_isEnabled); ++ ++ public async Task ConsumeAsync(IDataProducer dataProducer, IData value, CancellationToken cancellationToken) ++ { ++ cancellationToken.ThrowIfCancellationRequested(); ++ ++ if (value is not TestNodeUpdateMessage nodeUpdateMessage) ++ { ++ return; ++ } ++ ++ TestNodeStateProperty? nodeState = nodeUpdateMessage.TestNode.Properties.SingleOrDefault(); ++ string testDisplayName = nodeUpdateMessage.TestNode.DisplayName; ++ ++ switch (nodeState) ++ { ++ case FailedTestNodeStateProperty failed: ++ await WriteAnnotationAsync(testDisplayName, GetTestName(nodeUpdateMessage.TestNode), failed.Explanation, failed.Exception, cancellationToken).ConfigureAwait(false); ++ break; ++ case ErrorTestNodeStateProperty error: ++ await WriteAnnotationAsync(testDisplayName, GetTestName(nodeUpdateMessage.TestNode), error.Explanation, error.Exception, cancellationToken).ConfigureAwait(false); ++ break; ++ case TimeoutTestNodeStateProperty timeout: ++ await WriteAnnotationAsync(testDisplayName, GetTestName(nodeUpdateMessage.TestNode), timeout.Explanation, timeout.Exception, cancellationToken).ConfigureAwait(false); ++ break; ++#pragma warning disable CS0618, MTP0001 // Type or member is obsolete ++ case CancelledTestNodeStateProperty cancelled: ++#pragma warning restore CS0618, MTP0001 // Type or member is obsolete ++ await WriteAnnotationAsync(testDisplayName, GetTestName(nodeUpdateMessage.TestNode), cancelled.Explanation, cancelled.Exception, cancellationToken).ConfigureAwait(false); ++ break; ++ } ++ } ++ ++ private async Task WriteAnnotationAsync(string testDisplayName, string testName, string? explanation, Exception? exception, CancellationToken cancellationToken) ++ { ++ if (_logger.IsEnabled(LogLevel.Trace)) ++ { ++ _logger.LogTrace("Failure received."); ++ } ++ ++ string repoRoot = GitHubActionsRepositoryRoot.Resolve(_environment) ?? string.Empty; ++ string line = GetErrorAnnotation(testName, explanation, exception, repoRoot, _fileSystem, _logger); ++ ++ if (_logger.IsEnabled(LogLevel.Trace)) ++ { ++ _logger.LogTrace($"Showing failure annotation '{line}'."); ++ } ++ ++ await _outputDisplay.DisplayAsync(this, new FormattedTextOutputDeviceData(line), cancellationToken).ConfigureAwait(false); ++ } ++ ++ internal static /* for testing */ string GetErrorAnnotation(string testName, string? explanation, Exception? exception, string? repoRoot, IFileSystem fileSystem, ILogger logger) ++ { ++ string message = explanation ?? exception?.Message ?? GitHubActionsResources.NoFailureMessageFallback; ++ string title = string.Format(CultureInfo.InvariantCulture, GitHubActionsResources.AnnotationTitle, testName); ++ ++ (string File, int Line)? location = TryGetSourceLocation(exception, repoRoot, fileSystem, logger); ++ if (location is not null) ++ { ++ return string.Format( ++ CultureInfo.InvariantCulture, ++ "::error file={0},line={1},col=1,title={2}::{3}", ++ GitHubActionsEscaper.EscapeProperty(location.Value.File), ++ location.Value.Line.ToString(CultureInfo.InvariantCulture), ++ GitHubActionsEscaper.EscapeProperty(title), ++ GitHubActionsEscaper.EscapeData(message)); ++ } ++ ++ // Fallback: source location could not be resolved. The file/line/col properties are optional; ++ // a title-only annotation still surfaces in the workflow Annotations tab. ++ return string.Format( ++ CultureInfo.InvariantCulture, ++ "::error title={0}::{1}", ++ GitHubActionsEscaper.EscapeProperty(title), ++ GitHubActionsEscaper.EscapeData(message)); ++ } ++ ++ private static (string File, int Line)? TryGetSourceLocation(Exception? exception, string? repoRoot, IFileSystem fileSystem, ILogger logger) ++ { ++ if (exception?.StackTrace is not { } stackTrace || RoslynString.IsNullOrEmpty(repoRoot)) ++ { ++ return null; ++ } ++ ++ foreach (string stackFrame in stackTrace.Split(NewlineCharacters, StringSplitOptions.RemoveEmptyEntries)) ++ { ++ (string Code, string File, int LineNumber)? location = GetStackFrameLocation(stackFrame); ++ if (location is null) ++ { ++ continue; ++ } ++ ++ string file = location.Value.File; ++ string code = location.Value.Code; ++ ++ if (IsAssertionImplementationFrame(code)) ++ { ++ continue; ++ } ++ ++ string relativePath; ++ if (file.StartsWith(DeterministicBuildRoot, StringComparison.OrdinalIgnoreCase)) ++ { ++ relativePath = file.Substring(DeterministicBuildRoot.Length); ++ } ++ else if (file.StartsWith(repoRoot, StringComparison.OrdinalIgnoreCase)) ++ { ++ relativePath = file.Substring(repoRoot.Length); ++ } ++ else ++ { ++ continue; ++ } ++ ++ string fullPath = Path.Combine(repoRoot, relativePath); ++ if (!fileSystem.ExistFile(fullPath)) ++ { ++ continue; ++ } ++ ++ // GitHub annotations expect a workspace-relative path with forward slashes. ++ string relativeNormalizedPath = relativePath.Replace('\\', '/').TrimStart('/'); ++ if (logger.IsEnabled(LogLevel.Trace)) ++ { ++ logger.LogTrace($"Normalized path for GitHub annotation '{relativeNormalizedPath}'."); ++ } ++ ++ return (relativeNormalizedPath, location.Value.LineNumber); ++ } ++ ++ return null; ++ } ++ ++ private static bool IsAssertionImplementationFrame(string code) ++ { ++ foreach (string prefix in AssertionImplementationCodePrefixes) ++ { ++ if (code.StartsWith(prefix, StringComparison.Ordinal)) ++ { ++ return true; ++ } ++ } ++ ++ return false; ++ } ++ ++ private static (string Code, string File, int LineNumber)? GetStackFrameLocation(string stackTraceLine) ++ { ++ Match match = StackTraceHelper.GetFrameRegex().Match(stackTraceLine); ++ if (!match.Success) ++ { ++ return null; ++ } ++ ++ string code = match.Groups["code"].Value; ++ if (RoslynString.IsNullOrWhiteSpace(code)) ++ { ++ return null; ++ } ++ ++ string file = match.Groups["file"].Value; ++ if (RoslynString.IsNullOrWhiteSpace(file)) ++ { ++ return null; ++ } ++ ++ int line = int.TryParse(match.Groups["line"].Value, out int value) ? value : 0; ++ return (code, file, line); ++ } ++ ++ private static string GetTestName(TestNode testNode) ++ => TestNodeIdentity.GetTestName(testNode); ++} +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsCommandLineOptions.cs b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsCommandLineOptions.cs +index 5a21b2acf4..6606688b3f 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsCommandLineOptions.cs ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsCommandLineOptions.cs +@@ -7,6 +7,11 @@ internal static class GitHubActionsCommandLineOptions + { + public const string GitHubActionsOptionName = "report-gh"; + public const string GitHubActionsGroups = "report-gh-groups"; ++ public const string GitHubActionsAnnotations = "report-gh-annotations"; ++ public const string GitHubActionsStepSummary = "report-gh-step-summary"; ++ public const string GitHubActionsSlowTestNotices = "report-gh-slow-test-notices"; + public const string OptionOn = "on"; + public const string OptionOff = "off"; ++ ++ public const int SlowTestThresholdDefaultSeconds = 60; + } +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsCommandLineProvider.cs b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsCommandLineProvider.cs +index 27ce5e5540..10d98acd6f 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsCommandLineProvider.cs ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsCommandLineProvider.cs +@@ -20,6 +20,7 @@ public GitHubActionsCommandLineProvider() + GitHubActionsResources.Description, + [ + new CommandLineOption(GitHubActionsCommandLineOptions.GitHubActionsGroups, GitHubActionsResources.GroupsOptionDescription, ArgumentArity.ExactlyOne, false), ++ new CommandLineOption(GitHubActionsCommandLineOptions.GitHubActionsAnnotations, GitHubActionsResources.AnnotationsOptionDescription, ArgumentArity.ExactlyOne, false), + new CommandLineOption(GitHubActionsCommandLineOptions.GitHubActionsOptionName, GitHubActionsResources.OptionDescription, ArgumentArity.Zero, false), + ]) + { +@@ -28,7 +29,8 @@ public GitHubActionsCommandLineProvider() + public override Task ValidateOptionArgumentsAsync(CommandLineOption commandOption, string[] arguments) + => commandOption.Name switch + { +- GitHubActionsCommandLineOptions.GitHubActionsGroups when !OnOffOptions.Contains(arguments[0], StringComparer.OrdinalIgnoreCase) ++ GitHubActionsCommandLineOptions.GitHubActionsGroups or GitHubActionsCommandLineOptions.GitHubActionsAnnotations ++ when !OnOffOptions.Contains(arguments[0], StringComparer.OrdinalIgnoreCase) + => ValidationResult.InvalidTask(string.Format(CultureInfo.InvariantCulture, GitHubActionsResources.InvalidOnOffValue, arguments[0])), + _ => ValidationResult.ValidTask, + }; +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsEscaper.cs b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsEscaper.cs +index f81bde3717..c37531ca2c 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsEscaper.cs ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsEscaper.cs +@@ -40,4 +40,46 @@ public static string EscapeData(string value) + + return result.ToString(); + } ++ ++ /// ++ /// Escapes a value used as a property of a GitHub Actions workflow command (e.g. the file, ++ /// line, col or title of an ::error command). In addition to the data ++ /// escapes, properties must also escape : and , because those characters delimit the ++ /// command syntax. See . ++ /// ++ public static string EscapeProperty(string value) ++ { ++ if (RoslynString.IsNullOrEmpty(value)) ++ { ++ return value; ++ } ++ ++ var result = new StringBuilder(value.Length); ++ foreach (char c in value) ++ { ++ switch (c) ++ { ++ case '%': ++ result.Append("%25"); ++ break; ++ case '\r': ++ result.Append("%0D"); ++ break; ++ case '\n': ++ result.Append("%0A"); ++ break; ++ case ':': ++ result.Append("%3A"); ++ break; ++ case ',': ++ result.Append("%2C"); ++ break; ++ default: ++ result.Append(c); ++ break; ++ } ++ } ++ ++ return result.ToString(); ++ } + } +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsExtensions.cs b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsExtensions.cs +index c59deb5ffc..46152378e8 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsExtensions.cs ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsExtensions.cs +@@ -27,6 +27,14 @@ public static void AddGitHubActionsProvider(this ITestApplicationBuilder builder + serviceProvider.GetTestApplicationModuleInfo(), + serviceProvider.GetLoggerFactory())); + ++ builder.TestHost.AddDataConsumer(serviceProvider => ++ new GitHubActionsAnnotationReporter( ++ serviceProvider.GetCommandLineOptions(), ++ serviceProvider.GetEnvironment(), ++ serviceProvider.GetFileSystem(), ++ serviceProvider.GetOutputDevice(), ++ serviceProvider.GetLoggerFactory())); ++ + builder.TestHost.AddTestSessionLifetimeHandler(compositeReporter); + builder.CommandLine.AddProvider(() => new GitHubActionsCommandLineProvider()); + } +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsFeature.cs b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsFeature.cs +new file mode 100644 +index 0000000000..b53168ccc5 +--- /dev/null ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsFeature.cs +@@ -0,0 +1,31 @@ ++// Copyright (c) Microsoft Corporation. All rights reserved. ++// Licensed under the MIT license. See LICENSE file in the project root for full license information. ++ ++using Microsoft.Testing.Platform.CommandLine; ++using Microsoft.Testing.Platform.Helpers; ++ ++namespace Microsoft.Testing.Extensions.GitHubActionsReport; ++ ++/// ++/// Shared activation logic for the GitHub Actions reporters. The extension auto-activates when running ++/// on GitHub Actions (GITHUB_ACTIONS=true) or when the --report-gh master switch is set; ++/// each individual feature is then on by default but can be turned off with its --report-gh-* ++/// knob set to off. ++/// ++internal static class GitHubActionsFeature ++{ ++ public static bool IsRunningOnGitHubActions(IEnvironment environment) ++ => string.Equals(environment.GetEnvironmentVariable("GITHUB_ACTIONS"), "true", StringComparison.OrdinalIgnoreCase); ++ ++ public static bool IsMasterEnabled(ICommandLineOptions commandLine, IEnvironment environment) ++ => IsRunningOnGitHubActions(environment) ++ || commandLine.IsOptionSet(GitHubActionsCommandLineOptions.GitHubActionsOptionName); ++ ++ public static bool IsKnobEnabled(ICommandLineOptions commandLine, string knobOptionName) ++ => !(commandLine.TryGetOptionArgumentList(knobOptionName, out string[]? arguments) ++ && arguments is [string value] ++ && string.Equals(value, GitHubActionsCommandLineOptions.OptionOff, StringComparison.OrdinalIgnoreCase)); ++ ++ public static bool IsEnabled(ICommandLineOptions commandLine, IEnvironment environment, string knobOptionName) ++ => IsMasterEnabled(commandLine, environment) && IsKnobEnabled(commandLine, knobOptionName); ++} +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsReporter.cs b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsReporter.cs +index 811441203c..3ba6f1d9c8 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsReporter.cs ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsReporter.cs +@@ -53,21 +53,11 @@ public GitHubActionsReporter( + /// + public Task IsEnabledAsync() + { +- // The groups feature is on by default. It can be turned off explicitly via '--report-gh-groups off'. +- if (_commandLine.TryGetOptionArgumentList(GitHubActionsCommandLineOptions.GitHubActionsGroups, out string[]? groupsArguments) +- && groupsArguments is [string value] +- && string.Equals(value, GitHubActionsCommandLineOptions.OptionOff, StringComparison.OrdinalIgnoreCase)) +- { +- return Task.FromResult(false); +- } +- +- // Auto-activate when running on GitHub Actions, but only no-op otherwise. +- bool isGitHubActions = string.Equals(_environment.GetEnvironmentVariable("GITHUB_ACTIONS"), "true", StringComparison.OrdinalIgnoreCase); +- bool isEnabled = isGitHubActions || _commandLine.IsOptionSet(GitHubActionsCommandLineOptions.GitHubActionsOptionName); ++ bool isEnabled = GitHubActionsFeature.IsEnabled(_commandLine, _environment, GitHubActionsCommandLineOptions.GitHubActionsGroups); + + if (_logger.IsEnabled(LogLevel.Trace)) + { +- _logger.LogTrace($"{nameof(GitHubActionsReport)} is {(isEnabled ? "enabled" : "disabled")}."); ++ _logger.LogTrace($"{nameof(GitHubActionsReport)} groups is {(isEnabled ? "enabled" : "disabled")}."); + } + + return Task.FromResult(isEnabled); +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsRepositoryRoot.cs b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsRepositoryRoot.cs +new file mode 100644 +index 0000000000..0ba6abcd53 +--- /dev/null ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsRepositoryRoot.cs +@@ -0,0 +1,58 @@ ++// Copyright (c) Microsoft Corporation. All rights reserved. ++// Licensed under the MIT license. See LICENSE file in the project root for full license information. ++ ++using Microsoft.Testing.Platform; ++using Microsoft.Testing.Platform.Helpers; ++ ++namespace Microsoft.Testing.Extensions.GitHubActionsReport; ++ ++/// ++/// Resolves the repository root used to turn an absolute source-file path from a stack trace into a ++/// workspace-relative path for a GitHub Actions annotation. On a runner this is the ++/// GITHUB_WORKSPACE directory; off a runner (e.g. local runs and unit tests) it walks up from the ++/// application base directory looking for a .git directory or file. ++/// ++internal static class GitHubActionsRepositoryRoot ++{ ++ private static string? s_cachedGitRoot; ++ ++ public static string? Resolve(IEnvironment environment) ++ { ++ string? workspace = environment.GetEnvironmentVariable("GITHUB_WORKSPACE"); ++ return !RoslynString.IsNullOrWhiteSpace(workspace) ++ ? EnsureTrailingSeparator(workspace!) ++ : FindGitRoot(); ++ } ++ ++ internal static /* for testing */ string? FindGitRoot() ++ { ++ if (s_cachedGitRoot is not null) ++ { ++ return s_cachedGitRoot; ++ } ++ ++ string currentDirectory = AppContext.BaseDirectory; ++ string rootDriveDirectory = Directory.GetDirectoryRoot(currentDirectory); ++ while (!string.Equals(rootDriveDirectory, currentDirectory, StringComparison.Ordinal)) ++ { ++ string gitPath = Path.Combine(currentDirectory, ".git"); ++ ++ // When working with git worktrees, the .git is a file not a folder. ++ if (Directory.Exists(gitPath) || File.Exists(gitPath)) ++ { ++ s_cachedGitRoot = currentDirectory + Path.DirectorySeparatorChar; ++ return s_cachedGitRoot; ++ } ++ ++ currentDirectory = Directory.GetParent(currentDirectory)!.ToString(); ++ } ++ ++ return null; ++ } ++ ++ private static string EnsureTrailingSeparator(string path) ++ => path.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal) ++ || path.EndsWith(Path.AltDirectorySeparatorChar.ToString(), StringComparison.Ordinal) ++ ? path ++ : path + Path.DirectorySeparatorChar; ++} +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/GitHubActionsResources.resx b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/GitHubActionsResources.resx +index b37a3b8e43..cd33baa386 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/GitHubActionsResources.resx ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/GitHubActionsResources.resx +@@ -61,6 +61,17 @@ + + GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + ++ ++ Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"} ++ ++ ++ Test failed: {0} ++ {0} is the fully qualified test name. ++ ++ ++ The test failed without providing a failure message. ++ + + GitHub Actions report generator + +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.cs.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.cs.xlf +index 81eddc21b1..5bfb8140b5 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.cs.xlf ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.cs.xlf +@@ -2,6 +2,16 @@ + + + ++ ++ Test failed: {0} ++ Test failed: {0} ++ {0} is the fully qualified test name. ++ ++ ++ Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"} ++ + + GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. +@@ -27,6 +37,11 @@ + Invalid value '{0}'. Valid values are 'on' and 'off'. + {Locked="on"}{Locked="off"} + ++ ++ The test failed without providing a failure message. ++ The test failed without providing a failure message. ++ ++ + + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.de.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.de.xlf +index 038d961cfc..d6f91e3061 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.de.xlf ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.de.xlf +@@ -2,6 +2,16 @@ + + + ++ ++ Test failed: {0} ++ Test failed: {0} ++ {0} is the fully qualified test name. ++ ++ ++ Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"} ++ + + GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. +@@ -27,6 +37,11 @@ + Invalid value '{0}'. Valid values are 'on' and 'off'. + {Locked="on"}{Locked="off"} + ++ ++ The test failed without providing a failure message. ++ The test failed without providing a failure message. ++ ++ + + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.es.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.es.xlf +index 160e36f516..83f78f151e 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.es.xlf ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.es.xlf +@@ -2,6 +2,16 @@ + + + ++ ++ Test failed: {0} ++ Test failed: {0} ++ {0} is the fully qualified test name. ++ ++ ++ Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"} ++ + + GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. +@@ -27,6 +37,11 @@ + Invalid value '{0}'. Valid values are 'on' and 'off'. + {Locked="on"}{Locked="off"} + ++ ++ The test failed without providing a failure message. ++ The test failed without providing a failure message. ++ ++ + + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.fr.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.fr.xlf +index 8b091aee4f..48197eb65a 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.fr.xlf ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.fr.xlf +@@ -2,6 +2,16 @@ + + + ++ ++ Test failed: {0} ++ Test failed: {0} ++ {0} is the fully qualified test name. ++ ++ ++ Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"} ++ + + GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. +@@ -27,6 +37,11 @@ + Invalid value '{0}'. Valid values are 'on' and 'off'. + {Locked="on"}{Locked="off"} + ++ ++ The test failed without providing a failure message. ++ The test failed without providing a failure message. ++ ++ + + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.it.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.it.xlf +index 66e1007b08..f9c0d9853c 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.it.xlf ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.it.xlf +@@ -2,6 +2,16 @@ + + + ++ ++ Test failed: {0} ++ Test failed: {0} ++ {0} is the fully qualified test name. ++ ++ ++ Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"} ++ + + GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. +@@ -27,6 +37,11 @@ + Invalid value '{0}'. Valid values are 'on' and 'off'. + {Locked="on"}{Locked="off"} + ++ ++ The test failed without providing a failure message. ++ The test failed without providing a failure message. ++ ++ + + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ja.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ja.xlf +index e5c553b74d..6459729d11 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ja.xlf ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ja.xlf +@@ -2,6 +2,16 @@ + + + ++ ++ Test failed: {0} ++ Test failed: {0} ++ {0} is the fully qualified test name. ++ ++ ++ Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"} ++ + + GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. +@@ -27,6 +37,11 @@ + Invalid value '{0}'. Valid values are 'on' and 'off'. + {Locked="on"}{Locked="off"} + ++ ++ The test failed without providing a failure message. ++ The test failed without providing a failure message. ++ ++ + + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ko.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ko.xlf +index 60d6a9ca54..e569b20196 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ko.xlf ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ko.xlf +@@ -2,6 +2,16 @@ + + + ++ ++ Test failed: {0} ++ Test failed: {0} ++ {0} is the fully qualified test name. ++ ++ ++ Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"} ++ + + GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. +@@ -27,6 +37,11 @@ + Invalid value '{0}'. Valid values are 'on' and 'off'. + {Locked="on"}{Locked="off"} + ++ ++ The test failed without providing a failure message. ++ The test failed without providing a failure message. ++ ++ + + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.pl.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.pl.xlf +index 5eb8b2054d..a41a999a04 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.pl.xlf ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.pl.xlf +@@ -2,6 +2,16 @@ + + + ++ ++ Test failed: {0} ++ Test failed: {0} ++ {0} is the fully qualified test name. ++ ++ ++ Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"} ++ + + GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. +@@ -27,6 +37,11 @@ + Invalid value '{0}'. Valid values are 'on' and 'off'. + {Locked="on"}{Locked="off"} + ++ ++ The test failed without providing a failure message. ++ The test failed without providing a failure message. ++ ++ + + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.pt-BR.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.pt-BR.xlf +index fb5fd10afe..0a0aaf4f85 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.pt-BR.xlf ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.pt-BR.xlf +@@ -2,6 +2,16 @@ + + + ++ ++ Test failed: {0} ++ Test failed: {0} ++ {0} is the fully qualified test name. ++ ++ ++ Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"} ++ + + GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. +@@ -27,6 +37,11 @@ + Invalid value '{0}'. Valid values are 'on' and 'off'. + {Locked="on"}{Locked="off"} + ++ ++ The test failed without providing a failure message. ++ The test failed without providing a failure message. ++ ++ + + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ru.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ru.xlf +index 77613b3487..0721aa1c22 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ru.xlf ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ru.xlf +@@ -2,6 +2,16 @@ + + + ++ ++ Test failed: {0} ++ Test failed: {0} ++ {0} is the fully qualified test name. ++ ++ ++ Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"} ++ + + GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. +@@ -27,6 +37,11 @@ + Invalid value '{0}'. Valid values are 'on' and 'off'. + {Locked="on"}{Locked="off"} + ++ ++ The test failed without providing a failure message. ++ The test failed without providing a failure message. ++ ++ + + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.tr.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.tr.xlf +index 2311aaeca6..1df9dac473 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.tr.xlf ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.tr.xlf +@@ -2,6 +2,16 @@ + + + ++ ++ Test failed: {0} ++ Test failed: {0} ++ {0} is the fully qualified test name. ++ ++ ++ Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"} ++ + + GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. +@@ -27,6 +37,11 @@ + Invalid value '{0}'. Valid values are 'on' and 'off'. + {Locked="on"}{Locked="off"} + ++ ++ The test failed without providing a failure message. ++ The test failed without providing a failure message. ++ ++ + + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.zh-Hans.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.zh-Hans.xlf +index a908b510c2..8c21df64f1 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.zh-Hans.xlf ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.zh-Hans.xlf +@@ -2,6 +2,16 @@ + + + ++ ++ Test failed: {0} ++ Test failed: {0} ++ {0} is the fully qualified test name. ++ ++ ++ Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"} ++ + + GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. +@@ -27,6 +37,11 @@ + Invalid value '{0}'. Valid values are 'on' and 'off'. + {Locked="on"}{Locked="off"} + ++ ++ The test failed without providing a failure message. ++ The test failed without providing a failure message. ++ ++ + + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.zh-Hant.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.zh-Hant.xlf +index d20b0a8ebe..5d30212d13 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.zh-Hant.xlf ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.zh-Hant.xlf +@@ -2,6 +2,16 @@ + + + ++ ++ Test failed: {0} ++ Test failed: {0} ++ {0} is the fully qualified test name. ++ ++ ++ Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"} ++ + + GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. +@@ -27,6 +37,11 @@ + Invalid value '{0}'. Valid values are 'on' and 'off'. + {Locked="on"}{Locked="off"} + ++ ++ The test failed without providing a failure message. ++ The test failed without providing a failure message. ++ ++ + + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/TestNodeIdentity.cs b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/TestNodeIdentity.cs +new file mode 100644 +index 0000000000..f6d1cd53ec +--- /dev/null ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/TestNodeIdentity.cs +@@ -0,0 +1,30 @@ ++// Copyright (c) Microsoft Corporation. All rights reserved. ++// Licensed under the MIT license. See LICENSE file in the project root for full license information. ++ ++using Microsoft.Testing.Platform.Extensions.Messages; ++ ++namespace Microsoft.Testing.Extensions.GitHubActionsReport; ++ ++internal static class TestNodeIdentity ++{ ++ private const string FullyQualifiedNamePropertyKey = "vstest.TestCase.FullyQualifiedName"; ++ ++ /// ++ /// Resolves the stable, fully-qualified test name. Falls back to the display name when the ++ /// fully-qualified name property is unavailable. ++ /// ++ public static string GetTestName(TestNode testNode) ++ { ++ using PropertyBag.PropertyBagEnumerator enumerator = testNode.Properties.GetStructEnumerator(); ++ while (enumerator.MoveNext()) ++ { ++ if (enumerator.Current is SerializableKeyValuePairStringProperty kvp ++ && kvp.Key == FullyQualifiedNamePropertyKey) ++ { ++ return kvp.Value; ++ } ++ } ++ ++ return testNode.DisplayName; ++ } ++} +diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsAnnotationReporterTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsAnnotationReporterTests.cs +new file mode 100644 +index 0000000000..9e56d1bc23 +--- /dev/null ++++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsAnnotationReporterTests.cs +@@ -0,0 +1,84 @@ ++// Copyright (c) Microsoft Corporation. All rights reserved. ++// Licensed under the MIT license. See LICENSE file in the project root for full license information. ++ ++using Microsoft.Testing.Extensions.GitHubActionsReport; ++using Microsoft.Testing.Platform.Helpers; ++using Microsoft.Testing.Platform.Logging; ++ ++namespace Microsoft.Testing.Extensions.UnitTests; ++ ++[TestClass] ++public sealed class GitHubActionsAnnotationReporterTests ++{ ++ [TestMethod] ++ public void GetErrorAnnotation_ReportsFirstExistingFileWithLineColTitleAndEscaping() ++ { ++ Exception error; ++ try ++ { ++ throw new Exception("this is an error\nwith\rnewline"); ++ } ++ catch (Exception ex) ++ { ++ error = ex; ++ } ++ ++ var logger = new NoopLogger(); ++ string text = GitHubActionsAnnotationReporter.GetErrorAnnotation("MyNamespace.MyTest", null, error, GitHubActionsRepositoryRoot.FindGitRoot(), new SystemFileSystem(), logger); ++ ++ Assert.AreEqual( ++ "::error file=test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsAnnotationReporterTests.cs,line=19,col=1,title=Test failed%3A MyNamespace.MyTest::this is an error%0Awith%0Dnewline", ++ text); ++ } ++ ++ [TestMethod] ++ public void GetErrorAnnotation_PrefersExplanationOverExceptionMessage() ++ { ++ Exception error; ++ try ++ { ++ throw new Exception("exception message"); ++ } ++ catch (Exception ex) ++ { ++ error = ex; ++ } ++ ++ var logger = new NoopLogger(); ++ string text = GitHubActionsAnnotationReporter.GetErrorAnnotation("MyNamespace.MyTest", "Some custom reason\nwith\rnewline", error, GitHubActionsRepositoryRoot.FindGitRoot(), new SystemFileSystem(), logger); ++ ++ Assert.AreEqual( ++ "::error file=test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsAnnotationReporterTests.cs,line=40,col=1,title=Test failed%3A MyNamespace.MyTest::Some custom reason%0Awith%0Dnewline", ++ text); ++ } ++ ++ [TestMethod] ++ public void GetErrorAnnotation_FallsBackToTitleOnly_WhenNoSourceLocation() ++ { ++ var logger = new NoopLogger(); ++ string text = GitHubActionsAnnotationReporter.GetErrorAnnotation("MyNamespace.MyTest", "boom", exception: null, repoRoot: null, new SystemFileSystem(), logger); ++ ++ Assert.AreEqual("::error title=Test failed%3A MyNamespace.MyTest::boom", text); ++ } ++ ++ [TestMethod] ++ public void GetErrorAnnotation_UsesFallbackMessage_WhenNoExplanationOrException() ++ { ++ var logger = new NoopLogger(); ++ string text = GitHubActionsAnnotationReporter.GetErrorAnnotation("MyNamespace.MyTest", explanation: null, exception: null, repoRoot: null, new SystemFileSystem(), logger); ++ ++ Assert.IsTrue(text.StartsWith("::error title=Test failed%3A MyNamespace.MyTest::", StringComparison.Ordinal), text); ++ } ++ ++ private sealed class NoopLogger : ILogger ++ { ++ public bool IsEnabled(LogLevel logLevel) => false; ++ ++ public void Log(LogLevel logLevel, TState state, Exception? exception, Func formatter) ++ { ++ } ++ ++ public Task LogAsync(LogLevel logLevel, TState state, Exception? exception, Func formatter) ++ => Task.CompletedTask; ++ } ++} +diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsCommandLineProviderTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsCommandLineProviderTests.cs +index f25ef4a930..b772bf17f6 100644 +--- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsCommandLineProviderTests.cs ++++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsCommandLineProviderTests.cs +@@ -29,4 +29,24 @@ public async Task ValidateOptionArgumentsAsync_ReturnsValid_WhenGroupsValueIsOff + + Assert.IsTrue(validationResult.IsValid); + } ++ ++ [TestMethod] ++ public async Task ValidateOptionArgumentsAsync_ReturnsInvalid_WhenAnnotationsValueIsNotOnOrOffAsync() ++ { ++ GitHubActionsCommandLineProvider provider = new(); ++ CommandLineOption option = provider.GetCommandLineOptions().Single(o => o.Name == GitHubActionsCommandLineOptions.GitHubActionsAnnotations); ++ ValidationResult validationResult = await provider.ValidateOptionArgumentsAsync(option, ["maybe"]).ConfigureAwait(false); ++ ++ Assert.IsFalse(validationResult.IsValid); ++ } ++ ++ [TestMethod] ++ public async Task ValidateOptionArgumentsAsync_ReturnsValid_WhenAnnotationsValueIsOffAsync() ++ { ++ GitHubActionsCommandLineProvider provider = new(); ++ CommandLineOption option = provider.GetCommandLineOptions().Single(o => o.Name == GitHubActionsCommandLineOptions.GitHubActionsAnnotations); ++ ValidationResult validationResult = await provider.ValidateOptionArgumentsAsync(option, ["off"]).ConfigureAwait(false); ++ ++ Assert.IsTrue(validationResult.IsValid); ++ } + } +diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsEscaperTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsEscaperTests.cs +index d0ff29707a..a541050402 100644 +--- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsEscaperTests.cs ++++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsEscaperTests.cs +@@ -19,4 +19,12 @@ public void EscapeData_LeavesPlainTextUntouched() + [TestMethod] + public void EscapeData_ReturnsEmptyForEmpty() + => Assert.AreEqual(string.Empty, GitHubActionsEscaper.EscapeData(string.Empty)); ++ ++ [TestMethod] ++ public void EscapeProperty_EscapesPercentNewlinesColonAndComma() ++ => Assert.AreEqual("a%25b%0Ac%0Dd%3Ae%2Cf", GitHubActionsEscaper.EscapeProperty("a%b\nc\rd:e,f")); ++ ++ [TestMethod] ++ public void EscapeProperty_LeavesPlainTextUntouched() ++ => Assert.AreEqual("Test failed", GitHubActionsEscaper.EscapeProperty("Test failed")); + } + +From e139e54b91aac6a9365b74a9d8e458d85e3ff24e Mon Sep 17 00:00:00 2001 +From: Azat Mukhametshin +Date: Mon, 29 Jun 2026 20:39:09 +0200 +Subject: [PATCH 04/32] Add GitHub Actions job summary (#9142 requirement 3) + +Add GitHubActionsSummaryReporter that collects per-test outcomes and, at +session end, appends a markdown roll-up (totals table, failures list, +slowest tests) to the file named by GITHUB_STEP_SUMMARY, mirroring the +AzureDevOpsSummaryReporter structure. Adds the --report-gh-step-summary +on|off knob (default on) plus unit tests for the markdown shape. + +Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> +--- + .../GitHubActionsCommandLineProvider.cs | 3 +- + .../GitHubActionsExtensions.cs | 11 + + .../GitHubActionsSummaryReporter.cs | 357 ++++++++++++++++++ + .../Resources/GitHubActionsResources.resx | 8 + + .../xlf/GitHubActionsResources.cs.xlf | 10 + + .../xlf/GitHubActionsResources.de.xlf | 10 + + .../xlf/GitHubActionsResources.es.xlf | 10 + + .../xlf/GitHubActionsResources.fr.xlf | 10 + + .../xlf/GitHubActionsResources.it.xlf | 10 + + .../xlf/GitHubActionsResources.ja.xlf | 10 + + .../xlf/GitHubActionsResources.ko.xlf | 10 + + .../xlf/GitHubActionsResources.pl.xlf | 10 + + .../xlf/GitHubActionsResources.pt-BR.xlf | 10 + + .../xlf/GitHubActionsResources.ru.xlf | 10 + + .../xlf/GitHubActionsResources.tr.xlf | 10 + + .../xlf/GitHubActionsResources.zh-Hans.xlf | 10 + + .../xlf/GitHubActionsResources.zh-Hant.xlf | 10 + + .../GitHubActionsCommandLineProviderTests.cs | 10 + + .../GitHubActionsSummaryReporterTests.cs | 70 ++++ + 19 files changed, 588 insertions(+), 1 deletion(-) + create mode 100644 src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsSummaryReporter.cs + create mode 100644 test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsSummaryReporterTests.cs + +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsCommandLineProvider.cs b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsCommandLineProvider.cs +index 10d98acd6f..b3def8fca8 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsCommandLineProvider.cs ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsCommandLineProvider.cs +@@ -21,6 +21,7 @@ public GitHubActionsCommandLineProvider() + [ + new CommandLineOption(GitHubActionsCommandLineOptions.GitHubActionsGroups, GitHubActionsResources.GroupsOptionDescription, ArgumentArity.ExactlyOne, false), + new CommandLineOption(GitHubActionsCommandLineOptions.GitHubActionsAnnotations, GitHubActionsResources.AnnotationsOptionDescription, ArgumentArity.ExactlyOne, false), ++ new CommandLineOption(GitHubActionsCommandLineOptions.GitHubActionsStepSummary, GitHubActionsResources.StepSummaryOptionDescription, ArgumentArity.ExactlyOne, false), + new CommandLineOption(GitHubActionsCommandLineOptions.GitHubActionsOptionName, GitHubActionsResources.OptionDescription, ArgumentArity.Zero, false), + ]) + { +@@ -29,7 +30,7 @@ public GitHubActionsCommandLineProvider() + public override Task ValidateOptionArgumentsAsync(CommandLineOption commandOption, string[] arguments) + => commandOption.Name switch + { +- GitHubActionsCommandLineOptions.GitHubActionsGroups or GitHubActionsCommandLineOptions.GitHubActionsAnnotations ++ GitHubActionsCommandLineOptions.GitHubActionsGroups or GitHubActionsCommandLineOptions.GitHubActionsAnnotations or GitHubActionsCommandLineOptions.GitHubActionsStepSummary + when !OnOffOptions.Contains(arguments[0], StringComparer.OrdinalIgnoreCase) + => ValidationResult.InvalidTask(string.Format(CultureInfo.InvariantCulture, GitHubActionsResources.InvalidOnOffValue, arguments[0])), + _ => ValidationResult.ValidTask, +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsExtensions.cs b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsExtensions.cs +index 46152378e8..2c78a46396 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsExtensions.cs ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsExtensions.cs +@@ -27,6 +27,15 @@ public static void AddGitHubActionsProvider(this ITestApplicationBuilder builder + serviceProvider.GetTestApplicationModuleInfo(), + serviceProvider.GetLoggerFactory())); + ++ var compositeSummaryReporter = new CompositeExtensionFactory(serviceProvider => ++ new GitHubActionsSummaryReporter( ++ serviceProvider.GetCommandLineOptions(), ++ serviceProvider.GetEnvironment(), ++ serviceProvider.GetFileSystem(), ++ serviceProvider.GetOutputDevice(), ++ serviceProvider.GetTestApplicationModuleInfo(), ++ serviceProvider.GetLoggerFactory())); ++ + builder.TestHost.AddDataConsumer(serviceProvider => + new GitHubActionsAnnotationReporter( + serviceProvider.GetCommandLineOptions(), +@@ -35,6 +44,8 @@ public static void AddGitHubActionsProvider(this ITestApplicationBuilder builder + serviceProvider.GetOutputDevice(), + serviceProvider.GetLoggerFactory())); + ++ builder.TestHost.AddDataConsumer(compositeSummaryReporter); ++ builder.TestHost.AddTestSessionLifetimeHandler(compositeSummaryReporter); + builder.TestHost.AddTestSessionLifetimeHandler(compositeReporter); + builder.CommandLine.AddProvider(() => new GitHubActionsCommandLineProvider()); + } +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsSummaryReporter.cs b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsSummaryReporter.cs +new file mode 100644 +index 0000000000..dd74765e28 +--- /dev/null ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsSummaryReporter.cs +@@ -0,0 +1,357 @@ ++// Copyright (c) Microsoft Corporation. All rights reserved. ++// Licensed under the MIT license. See LICENSE file in the project root for full license information. ++ ++using Microsoft.Testing.Extensions.GitHubActionsReport.Resources; ++using Microsoft.Testing.Platform; ++using Microsoft.Testing.Platform.CommandLine; ++using Microsoft.Testing.Platform.Extensions; ++using Microsoft.Testing.Platform.Extensions.Messages; ++using Microsoft.Testing.Platform.Extensions.OutputDevice; ++using Microsoft.Testing.Platform.Extensions.TestHost; ++using Microsoft.Testing.Platform.Helpers; ++using Microsoft.Testing.Platform.Logging; ++using Microsoft.Testing.Platform.OutputDevice; ++using Microsoft.Testing.Platform.Services; ++ ++namespace Microsoft.Testing.Extensions.GitHubActionsReport; ++ ++/// ++/// Writes a markdown roll-up of the test run (totals, failures, slowest tests) to the file pointed to by ++/// the GITHUB_STEP_SUMMARY environment variable. GitHub renders that file on the workflow run's ++/// summary page. See ++/// . ++/// ++internal sealed class GitHubActionsSummaryReporter : ++ IDataConsumer, ++ ITestSessionLifetimeHandler, ++ IOutputDeviceDataProducer ++{ ++ private const string StepSummaryEnvironmentVariable = "GITHUB_STEP_SUMMARY"; ++ private const string FullyQualifiedNamePropertyKey = "vstest.TestCase.FullyQualifiedName"; ++ private const int MaxFailures = 20; ++ private const int MaxSlowestTests = 10; ++ ++ private readonly IEnvironment _environment; ++ private readonly IFileSystem _fileSystem; ++ private readonly IOutputDevice _outputDevice; ++ private readonly ITestApplicationModuleInfo _testApplicationModuleInfo; ++ private readonly ILogger _logger; ++ private readonly Lazy _targetFrameworkMoniker; ++ private readonly bool _isEnabled; ++ ++#if NET9_0_OR_GREATER ++ private readonly System.Threading.Lock _stateLock = new(); ++#else ++ private readonly object _stateLock = new(); ++#endif ++#pragma warning disable IDE0028 // Collection initialization can be simplified - target-typed `new` cannot pass the comparer in the same syntactic form expected. ++ private readonly Dictionary _records = new Dictionary(StringComparer.Ordinal); ++#pragma warning restore IDE0028 ++ ++ public GitHubActionsSummaryReporter( ++ ICommandLineOptions commandLineOptions, ++ IEnvironment environment, ++ IFileSystem fileSystem, ++ IOutputDevice outputDevice, ++ ITestApplicationModuleInfo testApplicationModuleInfo, ++ ILoggerFactory loggerFactory) ++ { ++ _environment = environment; ++ _fileSystem = fileSystem; ++ _outputDevice = outputDevice; ++ _testApplicationModuleInfo = testApplicationModuleInfo; ++ _logger = loggerFactory.CreateLogger(); ++ _targetFrameworkMoniker = new(TargetFrameworkMonikerHelper.GetTargetFrameworkMoniker); ++ _isEnabled = GitHubActionsFeature.IsEnabled(commandLineOptions, environment, GitHubActionsCommandLineOptions.GitHubActionsStepSummary); ++ } ++ ++ public Type[] DataTypesConsumed { get; } = [typeof(TestNodeUpdateMessage)]; ++ ++ public string Uid => nameof(GitHubActionsSummaryReporter); ++ ++ public string Version => ExtensionVersion.DefaultSemVer; ++ ++ public string DisplayName => GitHubActionsResources.DisplayName; ++ ++ public string Description => GitHubActionsResources.Description; ++ ++ public Task IsEnabledAsync() => Task.FromResult(_isEnabled); ++ ++ public Task OnTestSessionStartingAsync(ITestSessionContext testSessionContext) ++ { ++ lock (_stateLock) ++ { ++ _records.Clear(); ++ } ++ ++ return Task.CompletedTask; ++ } ++ ++ public Task ConsumeAsync(IDataProducer dataProducer, IData value, CancellationToken cancellationToken) ++ { ++ try ++ { ++ cancellationToken.ThrowIfCancellationRequested(); ++ ++ if (!_isEnabled || value is not TestNodeUpdateMessage update) ++ { ++ return Task.CompletedTask; ++ } ++ ++ TestNodeStateProperty? state = update.TestNode.Properties.SingleOrDefault(); ++ TerminalKind kind = GetTerminalKind(state); ++ if (kind == TerminalKind.NotTerminal) ++ { ++ return Task.CompletedTask; ++ } ++ ++ string uid = update.TestNode.Uid; ++ string displayName = update.TestNode.DisplayName; ++ ++ TimingProperty? timing = null; ++ string? fqnValue = null; ++ PropertyBag.PropertyBagEnumerator enumerator = update.TestNode.Properties.GetStructEnumerator(); ++ while (enumerator.MoveNext()) ++ { ++ switch (enumerator.Current) ++ { ++ case TimingProperty t: ++ timing = t; ++ break; ++ case SerializableKeyValuePairStringProperty kv when kv.Key == FullyQualifiedNamePropertyKey && fqnValue is null: ++ fqnValue = kv.Value; ++ break; ++ } ++ } ++ ++ string fullyQualifiedName = fqnValue ?? displayName; ++ TimeSpan duration = timing?.GlobalTiming.Duration ?? TimeSpan.Zero; ++ ++ lock (_stateLock) ++ { ++ _records[uid] = new TestRecord(displayName, fullyQualifiedName, kind, duration); ++ } ++ } ++ catch (OperationCanceledException) ++ { ++ throw; ++ } ++ catch (Exception ex) ++ { ++ LogUnexpectedException(nameof(ConsumeAsync), ex); ++ } ++ ++ return Task.CompletedTask; ++ } ++ ++ public async Task OnTestSessionFinishingAsync(ITestSessionContext testSessionContext) ++ { ++ try ++ { ++ testSessionContext.CancellationToken.ThrowIfCancellationRequested(); ++ ++ if (!_isEnabled) ++ { ++ return; ++ } ++ ++ string? path = _environment.GetEnvironmentVariable(StepSummaryEnvironmentVariable); ++ if (RoslynString.IsNullOrWhiteSpace(path)) ++ { ++ // Outside a GitHub Actions step (or when summaries are unsupported) there is nowhere to ++ // write. Stay quiet apart from a low-noise trace so local/dev runs don't get a warning. ++ if (_logger.IsEnabled(LogLevel.Trace)) ++ { ++ _logger.LogTrace($"'{StepSummaryEnvironmentVariable}' is not set; skipping job summary."); ++ } ++ ++ return; ++ } ++ ++ List snapshot; ++ lock (_stateLock) ++ { ++ snapshot = [.. _records.Values]; ++ } ++ ++ string markdown = BuildMarkdown(snapshot, _testApplicationModuleInfo.TryGetAssemblyName() ?? "unknown", _targetFrameworkMoniker.Value); ++ ++ try ++ { ++ using IFileStream stream = _fileSystem.NewFileStream(path!, FileMode.Append, FileAccess.Write, FileShare.Read); ++ using var writer = new StreamWriter(stream.Stream, new UTF8Encoding(false)); ++ await writer.WriteAsync(markdown).ConfigureAwait(false); ++ } ++ catch (Exception ex) when (ex is not OperationCanceledException) ++ { ++ string warning = string.Format(CultureInfo.InvariantCulture, GitHubActionsResources.StepSummaryWriteFailedWarning, path, ex.Message); ++ if (_logger.IsEnabled(LogLevel.Warning)) ++ { ++ _logger.LogWarning(warning); ++ } ++ ++ await _outputDevice.DisplayAsync(this, new WarningMessageOutputDeviceData(warning), testSessionContext.CancellationToken).ConfigureAwait(false); ++ } ++ } ++ catch (OperationCanceledException) ++ { ++ throw; ++ } ++ catch (Exception ex) ++ { ++ LogUnexpectedException(nameof(OnTestSessionFinishingAsync), ex); ++ } ++ } ++ ++ internal static /* for testing */ string BuildMarkdown(IReadOnlyList records, string assemblyName, string targetFrameworkMoniker) ++ { ++ int total = records.Count; ++ int passed = 0; ++ int failed = 0; ++ int skipped = 0; ++ TimeSpan totalDuration = TimeSpan.Zero; ++ var failures = new List(); ++ ++ foreach (TestRecord record in records) ++ { ++ totalDuration += record.Duration; ++ switch (record.Kind) ++ { ++ case TerminalKind.Passed: ++ passed++; ++ break; ++ case TerminalKind.Failed: ++ failed++; ++ if (failures.Count < MaxFailures) ++ { ++ failures.Add(record); ++ } ++ ++ break; ++ case TerminalKind.Skipped: ++ skipped++; ++ break; ++ } ++ } ++ ++ string statusIcon = failed > 0 ? "❌" : "✅"; ++ ++ var builder = new StringBuilder(); ++ builder.Append("## ").Append(statusIcon).Append(" Test Run Summary — ").Append(assemblyName).Append(" (").Append(targetFrameworkMoniker).Append(")\n\n"); ++ builder.Append("| Total | Passed | Failed | Skipped | Duration |\n"); ++ builder.Append("|---:|---:|---:|---:|---:|\n"); ++ builder.Append("| ").Append(total.ToString(CultureInfo.InvariantCulture)) ++ .Append(" | ").Append(passed.ToString(CultureInfo.InvariantCulture)) ++ .Append(" | ").Append(failed.ToString(CultureInfo.InvariantCulture)) ++ .Append(" | ").Append(skipped.ToString(CultureInfo.InvariantCulture)) ++ .Append(" | ").Append(FormatDuration(totalDuration)).Append(" |\n\n"); ++ ++ if (failures.Count > 0) ++ { ++ builder.Append("### ❌ Failures (").Append(failed.ToString(CultureInfo.InvariantCulture)).Append(")\n\n"); ++ foreach (TestRecord failure in failures) ++ { ++ builder.Append("- `").Append(EscapeInlineCode(failure.FullyQualifiedName)).Append("`\n"); ++ } ++ ++ builder.Append('\n'); ++ } ++ ++ IEnumerable slowest = records ++ .Where(static r => r.Duration > TimeSpan.Zero) ++ .OrderByDescending(static r => r.Duration) ++ .Take(MaxSlowestTests); ++ ++ bool slowestEmitted = false; ++ foreach (TestRecord record in slowest) ++ { ++ if (!slowestEmitted) ++ { ++ builder.Append("### ⏱ Slowest tests\n\n"); ++ slowestEmitted = true; ++ } ++ ++ builder.Append("- `").Append(EscapeInlineCode(record.FullyQualifiedName)).Append("` — ").Append(FormatDuration(record.Duration)).Append('\n'); ++ } ++ ++ if (slowestEmitted) ++ { ++ builder.Append('\n'); ++ } ++ ++ return builder.ToString(); ++ } ++ ++ private static string FormatDuration(TimeSpan duration) ++ { ++ if (duration < TimeSpan.FromSeconds(1)) ++ { ++ return string.Format(CultureInfo.InvariantCulture, "{0}ms", (int)duration.TotalMilliseconds); ++ } ++ ++ if (duration < TimeSpan.FromMinutes(1)) ++ { ++ return string.Format(CultureInfo.InvariantCulture, "{0:0.00}s", duration.TotalSeconds); ++ } ++ ++ if (duration < TimeSpan.FromHours(1)) ++ { ++ return string.Format(CultureInfo.InvariantCulture, "{0}m {1:00}s", (int)duration.TotalMinutes, duration.Seconds); ++ } ++ ++ long totalHours = (long)Math.Floor(duration.TotalHours); ++ return string.Format(CultureInfo.InvariantCulture, "{0}h {1:00}m {2:00}s", totalHours, duration.Minutes, duration.Seconds); ++ } ++ ++ private static string EscapeInlineCode(string value) ++ => RoslynString.IsNullOrEmpty(value) ? value : value.Replace("`", "'").Replace("\r", string.Empty).Replace("\n", " "); ++ ++ private static TerminalKind GetTerminalKind(TestNodeStateProperty? state) ++ => state switch ++ { ++ PassedTestNodeStateProperty => TerminalKind.Passed, ++ FailedTestNodeStateProperty => TerminalKind.Failed, ++ ErrorTestNodeStateProperty => TerminalKind.Failed, ++ TimeoutTestNodeStateProperty => TerminalKind.Failed, ++ SkippedTestNodeStateProperty => TerminalKind.Skipped, ++#pragma warning disable CS0618, MTP0001 ++ CancelledTestNodeStateProperty => TerminalKind.Failed, ++#pragma warning restore CS0618, MTP0001 ++ _ => TerminalKind.NotTerminal, ++ }; ++ ++ private void LogUnexpectedException(string callbackName, Exception ex) ++ { ++ if (_logger.IsEnabled(LogLevel.Warning)) ++ { ++ _logger.LogWarning($"Unexpected exception in {callbackName}: {ex}"); ++ } ++ } ++ ++ internal readonly struct TestRecord ++ { ++ public TestRecord(string displayName, string fullyQualifiedName, TerminalKind kind, TimeSpan duration) ++ { ++ DisplayName = displayName; ++ FullyQualifiedName = fullyQualifiedName; ++ Kind = kind; ++ Duration = duration; ++ } ++ ++ public string DisplayName { get; } ++ ++ public string FullyQualifiedName { get; } ++ ++ public TerminalKind Kind { get; } ++ ++ public TimeSpan Duration { get; } ++ } ++ ++ internal enum TerminalKind ++ { ++ NotTerminal, ++ Passed, ++ Failed, ++ Skipped, ++ } ++} +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/GitHubActionsResources.resx b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/GitHubActionsResources.resx +index cd33baa386..1fc942c83e 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/GitHubActionsResources.resx ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/GitHubActionsResources.resx +@@ -72,6 +72,14 @@ + + The test failed without providing a failure message. + ++ ++ Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"}{Locked="GITHUB_STEP_SUMMARY"} ++ ++ ++ Failed to write the GitHub Actions job summary to '{0}': {1} ++ {0} is the file path, {1} is the error message. ++ + + GitHub Actions report generator + +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.cs.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.cs.xlf +index 5bfb8140b5..231061c522 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.cs.xlf ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.cs.xlf +@@ -47,6 +47,16 @@ + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + + ++ ++ Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"}{Locked="GITHUB_STEP_SUMMARY"} ++ ++ ++ Failed to write the GitHub Actions job summary to '{0}': {1} ++ Failed to write the GitHub Actions job summary to '{0}': {1} ++ {0} is the file path, {1} is the error message. ++ + + + +\ No newline at end of file +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.de.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.de.xlf +index d6f91e3061..6934ae8993 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.de.xlf ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.de.xlf +@@ -47,6 +47,16 @@ + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + + ++ ++ Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"}{Locked="GITHUB_STEP_SUMMARY"} ++ ++ ++ Failed to write the GitHub Actions job summary to '{0}': {1} ++ Failed to write the GitHub Actions job summary to '{0}': {1} ++ {0} is the file path, {1} is the error message. ++ + + + +\ No newline at end of file +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.es.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.es.xlf +index 83f78f151e..f2613a3750 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.es.xlf ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.es.xlf +@@ -47,6 +47,16 @@ + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + + ++ ++ Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"}{Locked="GITHUB_STEP_SUMMARY"} ++ ++ ++ Failed to write the GitHub Actions job summary to '{0}': {1} ++ Failed to write the GitHub Actions job summary to '{0}': {1} ++ {0} is the file path, {1} is the error message. ++ + + + +\ No newline at end of file +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.fr.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.fr.xlf +index 48197eb65a..7d5560ef0b 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.fr.xlf ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.fr.xlf +@@ -47,6 +47,16 @@ + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + + ++ ++ Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"}{Locked="GITHUB_STEP_SUMMARY"} ++ ++ ++ Failed to write the GitHub Actions job summary to '{0}': {1} ++ Failed to write the GitHub Actions job summary to '{0}': {1} ++ {0} is the file path, {1} is the error message. ++ + + + +\ No newline at end of file +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.it.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.it.xlf +index f9c0d9853c..3500068eb2 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.it.xlf ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.it.xlf +@@ -47,6 +47,16 @@ + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + + ++ ++ Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"}{Locked="GITHUB_STEP_SUMMARY"} ++ ++ ++ Failed to write the GitHub Actions job summary to '{0}': {1} ++ Failed to write the GitHub Actions job summary to '{0}': {1} ++ {0} is the file path, {1} is the error message. ++ + + + +\ No newline at end of file +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ja.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ja.xlf +index 6459729d11..537ed7c6cf 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ja.xlf ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ja.xlf +@@ -47,6 +47,16 @@ + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + + ++ ++ Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"}{Locked="GITHUB_STEP_SUMMARY"} ++ ++ ++ Failed to write the GitHub Actions job summary to '{0}': {1} ++ Failed to write the GitHub Actions job summary to '{0}': {1} ++ {0} is the file path, {1} is the error message. ++ + + + +\ No newline at end of file +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ko.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ko.xlf +index e569b20196..68b3911b87 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ko.xlf ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ko.xlf +@@ -47,6 +47,16 @@ + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + + ++ ++ Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"}{Locked="GITHUB_STEP_SUMMARY"} ++ ++ ++ Failed to write the GitHub Actions job summary to '{0}': {1} ++ Failed to write the GitHub Actions job summary to '{0}': {1} ++ {0} is the file path, {1} is the error message. ++ + + + +\ No newline at end of file +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.pl.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.pl.xlf +index a41a999a04..b238f82231 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.pl.xlf ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.pl.xlf +@@ -47,6 +47,16 @@ + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + + ++ ++ Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"}{Locked="GITHUB_STEP_SUMMARY"} ++ ++ ++ Failed to write the GitHub Actions job summary to '{0}': {1} ++ Failed to write the GitHub Actions job summary to '{0}': {1} ++ {0} is the file path, {1} is the error message. ++ + + + +\ No newline at end of file +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.pt-BR.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.pt-BR.xlf +index 0a0aaf4f85..2a5201c5af 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.pt-BR.xlf ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.pt-BR.xlf +@@ -47,6 +47,16 @@ + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + + ++ ++ Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"}{Locked="GITHUB_STEP_SUMMARY"} ++ ++ ++ Failed to write the GitHub Actions job summary to '{0}': {1} ++ Failed to write the GitHub Actions job summary to '{0}': {1} ++ {0} is the file path, {1} is the error message. ++ + + + +\ No newline at end of file +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ru.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ru.xlf +index 0721aa1c22..59c68b8aa3 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ru.xlf ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ru.xlf +@@ -47,6 +47,16 @@ + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + + ++ ++ Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"}{Locked="GITHUB_STEP_SUMMARY"} ++ ++ ++ Failed to write the GitHub Actions job summary to '{0}': {1} ++ Failed to write the GitHub Actions job summary to '{0}': {1} ++ {0} is the file path, {1} is the error message. ++ + + + +\ No newline at end of file +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.tr.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.tr.xlf +index 1df9dac473..b7f2ff3860 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.tr.xlf ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.tr.xlf +@@ -47,6 +47,16 @@ + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + + ++ ++ Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"}{Locked="GITHUB_STEP_SUMMARY"} ++ ++ ++ Failed to write the GitHub Actions job summary to '{0}': {1} ++ Failed to write the GitHub Actions job summary to '{0}': {1} ++ {0} is the file path, {1} is the error message. ++ + + + +\ No newline at end of file +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.zh-Hans.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.zh-Hans.xlf +index 8c21df64f1..a4e55836c2 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.zh-Hans.xlf ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.zh-Hans.xlf +@@ -47,6 +47,16 @@ + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + + ++ ++ Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"}{Locked="GITHUB_STEP_SUMMARY"} ++ ++ ++ Failed to write the GitHub Actions job summary to '{0}': {1} ++ Failed to write the GitHub Actions job summary to '{0}': {1} ++ {0} is the file path, {1} is the error message. ++ + + + +\ No newline at end of file +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.zh-Hant.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.zh-Hant.xlf +index 5d30212d13..648ed1cbec 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.zh-Hant.xlf ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.zh-Hant.xlf +@@ -47,6 +47,16 @@ + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + + ++ ++ Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"}{Locked="GITHUB_STEP_SUMMARY"} ++ ++ ++ Failed to write the GitHub Actions job summary to '{0}': {1} ++ Failed to write the GitHub Actions job summary to '{0}': {1} ++ {0} is the file path, {1} is the error message. ++ + + + +\ No newline at end of file +diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsCommandLineProviderTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsCommandLineProviderTests.cs +index b772bf17f6..cfd860e41c 100644 +--- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsCommandLineProviderTests.cs ++++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsCommandLineProviderTests.cs +@@ -49,4 +49,14 @@ public async Task ValidateOptionArgumentsAsync_ReturnsValid_WhenAnnotationsValue + + Assert.IsTrue(validationResult.IsValid); + } ++ ++ [TestMethod] ++ public async Task ValidateOptionArgumentsAsync_ReturnsInvalid_WhenStepSummaryValueIsNotOnOrOffAsync() ++ { ++ GitHubActionsCommandLineProvider provider = new(); ++ CommandLineOption option = provider.GetCommandLineOptions().Single(o => o.Name == GitHubActionsCommandLineOptions.GitHubActionsStepSummary); ++ ValidationResult validationResult = await provider.ValidateOptionArgumentsAsync(option, ["maybe"]).ConfigureAwait(false); ++ ++ Assert.IsFalse(validationResult.IsValid); ++ } + } +diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsSummaryReporterTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsSummaryReporterTests.cs +new file mode 100644 +index 0000000000..1f6cdc01d1 +--- /dev/null ++++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsSummaryReporterTests.cs +@@ -0,0 +1,70 @@ ++// Copyright (c) Microsoft Corporation. All rights reserved. ++// Licensed under the MIT license. See LICENSE file in the project root for full license information. ++ ++using Microsoft.Testing.Extensions.GitHubActionsReport; ++ ++namespace Microsoft.Testing.Extensions.UnitTests; ++ ++[TestClass] ++public sealed class GitHubActionsSummaryReporterTests ++{ ++ [TestMethod] ++ public void BuildMarkdown_AllPassing_UsesSuccessIconAndTotals() ++ { ++ GitHubActionsSummaryReporter.TestRecord[] records = ++ [ ++ new("Add", "CalculatorTests.Add", GitHubActionsSummaryReporter.TerminalKind.Passed, TimeSpan.FromMilliseconds(10)), ++ new("Sub", "CalculatorTests.Sub", GitHubActionsSummaryReporter.TerminalKind.Passed, TimeSpan.FromMilliseconds(20)), ++ new("Skip", "CalculatorTests.Skip", GitHubActionsSummaryReporter.TerminalKind.Skipped, TimeSpan.Zero), ++ ]; ++ ++ string markdown = GitHubActionsSummaryReporter.BuildMarkdown(records, "CalculatorTests", "net9.0"); ++ ++ Assert.Contains("## ✅ Test Run Summary — CalculatorTests (net9.0)", markdown); ++ Assert.Contains("| 3 | 2 | 0 | 1 | 30ms |", markdown); ++ Assert.DoesNotContain("### ❌ Failures", markdown); ++ } ++ ++ [TestMethod] ++ public void BuildMarkdown_WithFailures_UsesFailureIconAndListsFailures() ++ { ++ GitHubActionsSummaryReporter.TestRecord[] records = ++ [ ++ new("Pass", "StringUtilsTests.Pass", GitHubActionsSummaryReporter.TerminalKind.Passed, TimeSpan.FromMilliseconds(5)), ++ new("Boom", "StringUtilsTests.Boom", GitHubActionsSummaryReporter.TerminalKind.Failed, TimeSpan.FromMilliseconds(7)), ++ ]; ++ ++ string markdown = GitHubActionsSummaryReporter.BuildMarkdown(records, "StringUtilsTests", "net9.0"); ++ ++ Assert.Contains("## ❌ Test Run Summary — StringUtilsTests (net9.0)", markdown); ++ Assert.Contains("### ❌ Failures (1)", markdown); ++ Assert.Contains("- `StringUtilsTests.Boom`", markdown); ++ } ++ ++ [TestMethod] ++ public void BuildMarkdown_EmitsSlowestTestsSortedByDuration() ++ { ++ GitHubActionsSummaryReporter.TestRecord[] records = ++ [ ++ new("Fast", "T.Fast", GitHubActionsSummaryReporter.TerminalKind.Passed, TimeSpan.FromMilliseconds(10)), ++ new("Slow", "T.Slow", GitHubActionsSummaryReporter.TerminalKind.Passed, TimeSpan.FromSeconds(65)), ++ ]; ++ ++ string markdown = GitHubActionsSummaryReporter.BuildMarkdown(records, "T", "net9.0"); ++ ++ Assert.Contains("### ⏱ Slowest tests", markdown); ++ int slowIndex = markdown.IndexOf("- `T.Slow` — 1m 05s", StringComparison.Ordinal); ++ int fastIndex = markdown.IndexOf("- `T.Fast` — 10ms", StringComparison.Ordinal); ++ Assert.IsGreaterThanOrEqualTo(0, slowIndex, markdown); ++ Assert.IsGreaterThan(slowIndex, fastIndex, markdown); ++ } ++ ++ [TestMethod] ++ public void BuildMarkdown_NoTests_StillEmitsHeaderAndZeroTotals() ++ { ++ string markdown = GitHubActionsSummaryReporter.BuildMarkdown([], "Empty", "net9.0"); ++ ++ Assert.Contains("## ✅ Test Run Summary — Empty (net9.0)", markdown); ++ Assert.Contains("| 0 | 0 | 0 | 0 | 0ms |", markdown); ++ } ++} + +From be554aebf8532172bc125f915f5f8b588d2fd514 Mon Sep 17 00:00:00 2001 +From: Azat Mukhametshin +Date: Mon, 29 Jun 2026 20:47:10 +0200 +Subject: [PATCH 05/32] Add GitHub Actions slow-test notices (#9142 requirement + 4) + +Add GitHubActionsSlowTestReporter that tracks in-progress tests and, on a +1s scan loop with exponential backoff, emits a ::notice workflow command +for any test still running past a threshold (default 60s), mirroring the +AzureDevOpsSlowTestReporter. Adds the --report-gh-slow-test-notices on|off +knob (default on) and a --report-gh-slow-test-threshold seconds option, +plus deterministic ScanOnce-driven unit tests. + +Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> +--- + .../GitHubActionsCommandLineOptions.cs | 1 + + .../GitHubActionsCommandLineProvider.cs | 7 +- + .../GitHubActionsExtensions.cs | 11 + + .../GitHubActionsSlowTestReporter.cs | 266 ++++++++++++++++++ + .../Resources/GitHubActionsResources.resx | 18 ++ + .../xlf/GitHubActionsResources.cs.xlf | 25 ++ + .../xlf/GitHubActionsResources.de.xlf | 25 ++ + .../xlf/GitHubActionsResources.es.xlf | 25 ++ + .../xlf/GitHubActionsResources.fr.xlf | 25 ++ + .../xlf/GitHubActionsResources.it.xlf | 25 ++ + .../xlf/GitHubActionsResources.ja.xlf | 25 ++ + .../xlf/GitHubActionsResources.ko.xlf | 25 ++ + .../xlf/GitHubActionsResources.pl.xlf | 25 ++ + .../xlf/GitHubActionsResources.pt-BR.xlf | 25 ++ + .../xlf/GitHubActionsResources.ru.xlf | 25 ++ + .../xlf/GitHubActionsResources.tr.xlf | 25 ++ + .../xlf/GitHubActionsResources.zh-Hans.xlf | 25 ++ + .../xlf/GitHubActionsResources.zh-Hant.xlf | 25 ++ + .../GitHubActionsCommandLineProviderTests.cs | 30 ++ + .../GitHubActionsSlowTestReporterTests.cs | 231 +++++++++++++++ + 20 files changed, 888 insertions(+), 1 deletion(-) + create mode 100644 src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsSlowTestReporter.cs + create mode 100644 test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsSlowTestReporterTests.cs + +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsCommandLineOptions.cs b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsCommandLineOptions.cs +index 6606688b3f..b64ed542e7 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsCommandLineOptions.cs ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsCommandLineOptions.cs +@@ -10,6 +10,7 @@ internal static class GitHubActionsCommandLineOptions + public const string GitHubActionsAnnotations = "report-gh-annotations"; + public const string GitHubActionsStepSummary = "report-gh-step-summary"; + public const string GitHubActionsSlowTestNotices = "report-gh-slow-test-notices"; ++ public const string GitHubActionsSlowTestThreshold = "report-gh-slow-test-threshold"; + public const string OptionOn = "on"; + public const string OptionOff = "off"; + +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsCommandLineProvider.cs b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsCommandLineProvider.cs +index b3def8fca8..aef3359baf 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsCommandLineProvider.cs ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsCommandLineProvider.cs +@@ -22,6 +22,8 @@ public GitHubActionsCommandLineProvider() + new CommandLineOption(GitHubActionsCommandLineOptions.GitHubActionsGroups, GitHubActionsResources.GroupsOptionDescription, ArgumentArity.ExactlyOne, false), + new CommandLineOption(GitHubActionsCommandLineOptions.GitHubActionsAnnotations, GitHubActionsResources.AnnotationsOptionDescription, ArgumentArity.ExactlyOne, false), + new CommandLineOption(GitHubActionsCommandLineOptions.GitHubActionsStepSummary, GitHubActionsResources.StepSummaryOptionDescription, ArgumentArity.ExactlyOne, false), ++ new CommandLineOption(GitHubActionsCommandLineOptions.GitHubActionsSlowTestNotices, GitHubActionsResources.SlowTestNoticesOptionDescription, ArgumentArity.ExactlyOne, false), ++ new CommandLineOption(GitHubActionsCommandLineOptions.GitHubActionsSlowTestThreshold, GitHubActionsResources.SlowTestThresholdOptionDescription, ArgumentArity.ExactlyOne, false), + new CommandLineOption(GitHubActionsCommandLineOptions.GitHubActionsOptionName, GitHubActionsResources.OptionDescription, ArgumentArity.Zero, false), + ]) + { +@@ -30,9 +32,12 @@ public GitHubActionsCommandLineProvider() + public override Task ValidateOptionArgumentsAsync(CommandLineOption commandOption, string[] arguments) + => commandOption.Name switch + { +- GitHubActionsCommandLineOptions.GitHubActionsGroups or GitHubActionsCommandLineOptions.GitHubActionsAnnotations or GitHubActionsCommandLineOptions.GitHubActionsStepSummary ++ GitHubActionsCommandLineOptions.GitHubActionsGroups or GitHubActionsCommandLineOptions.GitHubActionsAnnotations or GitHubActionsCommandLineOptions.GitHubActionsStepSummary or GitHubActionsCommandLineOptions.GitHubActionsSlowTestNotices + when !OnOffOptions.Contains(arguments[0], StringComparer.OrdinalIgnoreCase) + => ValidationResult.InvalidTask(string.Format(CultureInfo.InvariantCulture, GitHubActionsResources.InvalidOnOffValue, arguments[0])), ++ GitHubActionsCommandLineOptions.GitHubActionsSlowTestThreshold ++ when !(int.TryParse(arguments[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out int seconds) && seconds >= 1) ++ => ValidationResult.InvalidTask(string.Format(CultureInfo.InvariantCulture, GitHubActionsResources.InvalidSlowTestThreshold, arguments[0])), + _ => ValidationResult.ValidTask, + }; + } +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsExtensions.cs b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsExtensions.cs +index 2c78a46396..c939e5859e 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsExtensions.cs ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsExtensions.cs +@@ -36,6 +36,15 @@ public static void AddGitHubActionsProvider(this ITestApplicationBuilder builder + serviceProvider.GetTestApplicationModuleInfo(), + serviceProvider.GetLoggerFactory())); + ++ var compositeSlowTestReporter = new CompositeExtensionFactory(serviceProvider => ++ new GitHubActionsSlowTestReporter( ++ serviceProvider.GetCommandLineOptions(), ++ serviceProvider.GetEnvironment(), ++ serviceProvider.GetOutputDevice(), ++ serviceProvider.GetTask(), ++ serviceProvider.GetClock(), ++ serviceProvider.GetLoggerFactory())); ++ + builder.TestHost.AddDataConsumer(serviceProvider => + new GitHubActionsAnnotationReporter( + serviceProvider.GetCommandLineOptions(), +@@ -46,6 +55,8 @@ public static void AddGitHubActionsProvider(this ITestApplicationBuilder builder + + builder.TestHost.AddDataConsumer(compositeSummaryReporter); + builder.TestHost.AddTestSessionLifetimeHandler(compositeSummaryReporter); ++ builder.TestHost.AddDataConsumer(compositeSlowTestReporter); ++ builder.TestHost.AddTestSessionLifetimeHandler(compositeSlowTestReporter); + builder.TestHost.AddTestSessionLifetimeHandler(compositeReporter); + builder.CommandLine.AddProvider(() => new GitHubActionsCommandLineProvider()); + } +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsSlowTestReporter.cs b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsSlowTestReporter.cs +new file mode 100644 +index 0000000000..c54947243f +--- /dev/null ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsSlowTestReporter.cs +@@ -0,0 +1,266 @@ ++// Copyright (c) Microsoft Corporation. All rights reserved. ++// Licensed under the MIT license. See LICENSE file in the project root for full license information. ++ ++using Microsoft.Testing.Extensions.GitHubActionsReport.Resources; ++using Microsoft.Testing.Platform.CommandLine; ++using Microsoft.Testing.Platform.Extensions; ++using Microsoft.Testing.Platform.Extensions.Messages; ++using Microsoft.Testing.Platform.Extensions.OutputDevice; ++using Microsoft.Testing.Platform.Extensions.TestHost; ++using Microsoft.Testing.Platform.Helpers; ++using Microsoft.Testing.Platform.Logging; ++using Microsoft.Testing.Platform.OutputDevice; ++using Microsoft.Testing.Platform.Services; ++ ++namespace Microsoft.Testing.Extensions.GitHubActionsReport; ++ ++/// ++/// Surfaces tests that are still running past a per-test threshold as GitHub Actions ::notice ++/// workflow commands, mirroring AzureDevOpsSlowTestReporter. ++/// ++/// ++/// This is a self-contained emitter for the GitHub Actions host. Once the platform-level ++/// IProgressEnricher hook (issue #9139) ships, the surfacing/backoff logic should migrate onto it. ++/// ++internal sealed class GitHubActionsSlowTestReporter : IDataConsumer, ITestSessionLifetimeHandler, IOutputDeviceDataProducer ++{ ++ private static readonly TimeSpan ScanInterval = TimeSpan.FromSeconds(1); ++ ++ private readonly IOutputDevice _outputDevice; ++ private readonly ITask _task; ++ private readonly IClock _clock; ++ private readonly ILogger _logger; ++ private readonly ConcurrentDictionary _inProgress = new(StringComparer.Ordinal); ++ private readonly bool _isEnabled; ++ private readonly TimeSpan _threshold; ++ ++ private volatile bool _active; ++ private CancellationTokenSource? _loopCancellationTokenSource; ++ private Task? _loopTask; ++ ++ public GitHubActionsSlowTestReporter( ++ ICommandLineOptions commandLineOptions, ++ IEnvironment environment, ++ IOutputDevice outputDevice, ++ ITask task, ++ IClock clock, ++ ILoggerFactory loggerFactory) ++ { ++ _outputDevice = outputDevice; ++ _task = task; ++ _clock = clock; ++ _logger = loggerFactory.CreateLogger(); ++ _isEnabled = GitHubActionsFeature.IsEnabled(commandLineOptions, environment, GitHubActionsCommandLineOptions.GitHubActionsSlowTestNotices); ++ _threshold = TimeSpan.FromSeconds(GetThresholdSeconds(commandLineOptions)); ++ } ++ ++ public Type[] DataTypesConsumed { get; } = [typeof(TestNodeUpdateMessage)]; ++ ++ public string Uid => nameof(GitHubActionsSlowTestReporter); ++ ++ public string Version => ExtensionVersion.DefaultSemVer; ++ ++ public string DisplayName => GitHubActionsResources.DisplayName; ++ ++ public string Description => GitHubActionsResources.Description; ++ ++ public Task IsEnabledAsync() => Task.FromResult(_isEnabled); ++ ++ public Task OnTestSessionStartingAsync(ITestSessionContext testSessionContext) ++ { ++ try ++ { ++ testSessionContext.CancellationToken.ThrowIfCancellationRequested(); ++ ++ _active = false; ++ _inProgress.Clear(); ++ ++ if (!_isEnabled) ++ { ++ return Task.CompletedTask; ++ } ++ ++ _loopCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(testSessionContext.CancellationToken); ++ _active = true; ++ _loopTask = _task.RunLongRunning(() => ScanLoopAsync(_loopCancellationTokenSource.Token), nameof(GitHubActionsSlowTestReporter), _loopCancellationTokenSource.Token); ++ } ++ catch (OperationCanceledException) ++ { ++ throw; ++ } ++ catch (Exception ex) ++ { ++ LogUnexpectedException(nameof(OnTestSessionStartingAsync), ex); ++ } ++ ++ return Task.CompletedTask; ++ } ++ ++ public Task ConsumeAsync(IDataProducer dataProducer, IData value, CancellationToken cancellationToken) ++ { ++ try ++ { ++ cancellationToken.ThrowIfCancellationRequested(); ++ ++ if (!_active || value is not TestNodeUpdateMessage update) ++ { ++ return Task.CompletedTask; ++ } ++ ++ string uid = update.TestNode.Uid; ++ TestNodeStateProperty? state = update.TestNode.Properties.SingleOrDefault(); ++ if (state is InProgressTestNodeStateProperty) ++ { ++ string testName = TestNodeIdentity.GetTestName(update.TestNode); ++ _inProgress[uid] = new InProgressTest(testName, _clock.UtcNow, _threshold); ++ } ++ else if (state is not null) ++ { ++ _inProgress.TryRemove(uid, out _); ++ } ++ } ++ catch (OperationCanceledException) ++ { ++ throw; ++ } ++ catch (Exception ex) ++ { ++ LogUnexpectedException(nameof(ConsumeAsync), ex); ++ } ++ ++ return Task.CompletedTask; ++ } ++ ++ public async Task OnTestSessionFinishingAsync(ITestSessionContext testSessionContext) ++ { ++ _active = false; ++ ++ CancellationTokenSource? loopCancellationTokenSource = _loopCancellationTokenSource; ++ if (loopCancellationTokenSource is not null) ++ { ++#pragma warning disable VSTHRD103 // CancelAsync is unavailable on all target frameworks. ++ loopCancellationTokenSource.Cancel(); ++#pragma warning restore VSTHRD103 ++ } ++ ++ if (_loopTask is not null) ++ { ++ try ++ { ++ await _loopTask.ConfigureAwait(false); ++ } ++ catch (OperationCanceledException) ++ { ++ // Expected during normal shutdown. ++ } ++ catch (Exception ex) ++ { ++ LogUnexpectedException(nameof(OnTestSessionFinishingAsync), ex); ++ } ++ } ++ ++ loopCancellationTokenSource?.Dispose(); ++ _loopCancellationTokenSource = null; ++ _loopTask = null; ++ _inProgress.Clear(); ++ } ++ ++ private async Task ScanLoopAsync(CancellationToken cancellationToken) ++ { ++ while (!cancellationToken.IsCancellationRequested) ++ { ++ try ++ { ++ await _task.Delay(ScanInterval, cancellationToken).ConfigureAwait(false); ++ await ScanOnceAsync(_clock.UtcNow, cancellationToken).ConfigureAwait(false); ++ } ++ catch (OperationCanceledException) ++ { ++ return; ++ } ++ } ++ } ++ ++ // Internal for unit testing: performs a single surfacing pass at the given 'now'. ++ internal async Task ScanOnceAsync(DateTimeOffset now, CancellationToken cancellationToken) ++ { ++ foreach (KeyValuePair entry in _inProgress) ++ { ++ InProgressTest test = entry.Value; ++ TimeSpan elapsed = now - test.StartTime; ++ if (elapsed < test.NextEmitThreshold) ++ { ++ continue; ++ } ++ ++ // Exponential backoff so a genuinely stuck test does not spam the log: T, 2T, 4T, ... ++ long currentTicks = test.NextEmitThreshold.Ticks; ++ test.NextEmitThreshold = currentTicks > TimeSpan.MaxValue.Ticks / 2 ++ ? TimeSpan.MaxValue ++ : TimeSpan.FromTicks(currentTicks * 2); ++ ++ try ++ { ++ await EmitSlowTestAsync(test, elapsed, cancellationToken).ConfigureAwait(false); ++ } ++ catch (Exception ex) when (ex is not OperationCanceledException) ++ { ++ LogUnexpectedException(nameof(ScanOnceAsync), ex); ++ } ++ } ++ } ++ ++ private async Task EmitSlowTestAsync(InProgressTest test, TimeSpan elapsed, CancellationToken cancellationToken) ++ { ++ string line = BuildNoticeLine(test.TestName, elapsed); ++ await _outputDevice.DisplayAsync(this, new FormattedTextOutputDeviceData(line), cancellationToken).ConfigureAwait(false); ++ } ++ ++ internal static /* for testing */ string BuildNoticeLine(string testName, TimeSpan elapsed) ++ { ++ string message = string.Format( ++ CultureInfo.InvariantCulture, ++ GitHubActionsResources.SlowTestStillRunning, ++ testName, ++ ((long)elapsed.TotalSeconds).ToString(CultureInfo.InvariantCulture)); ++ string title = string.Format(CultureInfo.InvariantCulture, GitHubActionsResources.SlowTestNoticeTitle, testName); ++ ++ return string.Format( ++ CultureInfo.InvariantCulture, ++ "::notice title={0}::{1}", ++ GitHubActionsEscaper.EscapeProperty(title), ++ GitHubActionsEscaper.EscapeData(message)); ++ } ++ ++ private static int GetThresholdSeconds(ICommandLineOptions commandLineOptions) ++ => commandLineOptions.TryGetOptionArgumentList(GitHubActionsCommandLineOptions.GitHubActionsSlowTestThreshold, out string[]? arguments) ++ && arguments is [string value] ++ && int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int seconds) ++ && seconds >= 1 ++ ? seconds ++ : GitHubActionsCommandLineOptions.SlowTestThresholdDefaultSeconds; ++ ++ private void LogUnexpectedException(string callbackName, Exception ex) ++ { ++ if (_logger.IsEnabled(LogLevel.Warning)) ++ { ++ _logger.LogWarning($"Unexpected exception in {callbackName}: {ex}"); ++ } ++ } ++ ++ private sealed class InProgressTest ++ { ++ public InProgressTest(string testName, DateTimeOffset startTime, TimeSpan threshold) ++ { ++ TestName = testName; ++ StartTime = startTime; ++ NextEmitThreshold = threshold; ++ } ++ ++ public string TestName { get; } ++ ++ public DateTimeOffset StartTime { get; } ++ ++ public TimeSpan NextEmitThreshold { get; set; } ++ } ++} +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/GitHubActionsResources.resx b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/GitHubActionsResources.resx +index 1fc942c83e..579a7f0f30 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/GitHubActionsResources.resx ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/GitHubActionsResources.resx +@@ -80,6 +80,24 @@ + Failed to write the GitHub Actions job summary to '{0}': {1} + {0} is the file path, {1} is the error message. + ++ ++ Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"} ++ ++ ++ The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. ++ ++ ++ Slow test: {0} ++ {0} is the fully qualified test name. ++ ++ ++ {0} still running after {1}s ++ {0} is the fully qualified test name, {1} is the elapsed seconds. ++ ++ ++ Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. ++ + + GitHub Actions report generator + +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.cs.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.cs.xlf +index 231061c522..be2fbed7c2 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.cs.xlf ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.cs.xlf +@@ -37,6 +37,11 @@ + Invalid value '{0}'. Valid values are 'on' and 'off'. + {Locked="on"}{Locked="off"} + ++ ++ Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. ++ Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. ++ ++ + + The test failed without providing a failure message. + The test failed without providing a failure message. +@@ -47,6 +52,26 @@ + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + + ++ ++ Slow test: {0} ++ Slow test: {0} ++ {0} is the fully qualified test name. ++ ++ ++ Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"} ++ ++ ++ {0} still running after {1}s ++ {0} still running after {1}s ++ {0} is the fully qualified test name, {1} is the elapsed seconds. ++ ++ ++ The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. ++ The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. ++ ++ + + Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.de.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.de.xlf +index 6934ae8993..af245b124f 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.de.xlf ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.de.xlf +@@ -37,6 +37,11 @@ + Invalid value '{0}'. Valid values are 'on' and 'off'. + {Locked="on"}{Locked="off"} + ++ ++ Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. ++ Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. ++ ++ + + The test failed without providing a failure message. + The test failed without providing a failure message. +@@ -47,6 +52,26 @@ + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + + ++ ++ Slow test: {0} ++ Slow test: {0} ++ {0} is the fully qualified test name. ++ ++ ++ Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"} ++ ++ ++ {0} still running after {1}s ++ {0} still running after {1}s ++ {0} is the fully qualified test name, {1} is the elapsed seconds. ++ ++ ++ The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. ++ The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. ++ ++ + + Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.es.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.es.xlf +index f2613a3750..c0ef507eb8 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.es.xlf ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.es.xlf +@@ -37,6 +37,11 @@ + Invalid value '{0}'. Valid values are 'on' and 'off'. + {Locked="on"}{Locked="off"} + ++ ++ Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. ++ Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. ++ ++ + + The test failed without providing a failure message. + The test failed without providing a failure message. +@@ -47,6 +52,26 @@ + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + + ++ ++ Slow test: {0} ++ Slow test: {0} ++ {0} is the fully qualified test name. ++ ++ ++ Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"} ++ ++ ++ {0} still running after {1}s ++ {0} still running after {1}s ++ {0} is the fully qualified test name, {1} is the elapsed seconds. ++ ++ ++ The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. ++ The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. ++ ++ + + Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.fr.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.fr.xlf +index 7d5560ef0b..2dfbebbae0 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.fr.xlf ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.fr.xlf +@@ -37,6 +37,11 @@ + Invalid value '{0}'. Valid values are 'on' and 'off'. + {Locked="on"}{Locked="off"} + ++ ++ Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. ++ Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. ++ ++ + + The test failed without providing a failure message. + The test failed without providing a failure message. +@@ -47,6 +52,26 @@ + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + + ++ ++ Slow test: {0} ++ Slow test: {0} ++ {0} is the fully qualified test name. ++ ++ ++ Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"} ++ ++ ++ {0} still running after {1}s ++ {0} still running after {1}s ++ {0} is the fully qualified test name, {1} is the elapsed seconds. ++ ++ ++ The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. ++ The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. ++ ++ + + Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.it.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.it.xlf +index 3500068eb2..5820a68215 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.it.xlf ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.it.xlf +@@ -37,6 +37,11 @@ + Invalid value '{0}'. Valid values are 'on' and 'off'. + {Locked="on"}{Locked="off"} + ++ ++ Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. ++ Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. ++ ++ + + The test failed without providing a failure message. + The test failed without providing a failure message. +@@ -47,6 +52,26 @@ + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + + ++ ++ Slow test: {0} ++ Slow test: {0} ++ {0} is the fully qualified test name. ++ ++ ++ Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"} ++ ++ ++ {0} still running after {1}s ++ {0} still running after {1}s ++ {0} is the fully qualified test name, {1} is the elapsed seconds. ++ ++ ++ The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. ++ The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. ++ ++ + + Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ja.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ja.xlf +index 537ed7c6cf..f57965206a 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ja.xlf ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ja.xlf +@@ -37,6 +37,11 @@ + Invalid value '{0}'. Valid values are 'on' and 'off'. + {Locked="on"}{Locked="off"} + ++ ++ Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. ++ Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. ++ ++ + + The test failed without providing a failure message. + The test failed without providing a failure message. +@@ -47,6 +52,26 @@ + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + + ++ ++ Slow test: {0} ++ Slow test: {0} ++ {0} is the fully qualified test name. ++ ++ ++ Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"} ++ ++ ++ {0} still running after {1}s ++ {0} still running after {1}s ++ {0} is the fully qualified test name, {1} is the elapsed seconds. ++ ++ ++ The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. ++ The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. ++ ++ + + Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ko.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ko.xlf +index 68b3911b87..2cdaa3623f 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ko.xlf ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ko.xlf +@@ -37,6 +37,11 @@ + Invalid value '{0}'. Valid values are 'on' and 'off'. + {Locked="on"}{Locked="off"} + ++ ++ Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. ++ Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. ++ ++ + + The test failed without providing a failure message. + The test failed without providing a failure message. +@@ -47,6 +52,26 @@ + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + + ++ ++ Slow test: {0} ++ Slow test: {0} ++ {0} is the fully qualified test name. ++ ++ ++ Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"} ++ ++ ++ {0} still running after {1}s ++ {0} still running after {1}s ++ {0} is the fully qualified test name, {1} is the elapsed seconds. ++ ++ ++ The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. ++ The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. ++ ++ + + Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.pl.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.pl.xlf +index b238f82231..7e4e3c639b 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.pl.xlf ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.pl.xlf +@@ -37,6 +37,11 @@ + Invalid value '{0}'. Valid values are 'on' and 'off'. + {Locked="on"}{Locked="off"} + ++ ++ Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. ++ Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. ++ ++ + + The test failed without providing a failure message. + The test failed without providing a failure message. +@@ -47,6 +52,26 @@ + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + + ++ ++ Slow test: {0} ++ Slow test: {0} ++ {0} is the fully qualified test name. ++ ++ ++ Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"} ++ ++ ++ {0} still running after {1}s ++ {0} still running after {1}s ++ {0} is the fully qualified test name, {1} is the elapsed seconds. ++ ++ ++ The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. ++ The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. ++ ++ + + Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.pt-BR.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.pt-BR.xlf +index 2a5201c5af..45497c6b59 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.pt-BR.xlf ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.pt-BR.xlf +@@ -37,6 +37,11 @@ + Invalid value '{0}'. Valid values are 'on' and 'off'. + {Locked="on"}{Locked="off"} + ++ ++ Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. ++ Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. ++ ++ + + The test failed without providing a failure message. + The test failed without providing a failure message. +@@ -47,6 +52,26 @@ + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + + ++ ++ Slow test: {0} ++ Slow test: {0} ++ {0} is the fully qualified test name. ++ ++ ++ Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"} ++ ++ ++ {0} still running after {1}s ++ {0} still running after {1}s ++ {0} is the fully qualified test name, {1} is the elapsed seconds. ++ ++ ++ The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. ++ The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. ++ ++ + + Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ru.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ru.xlf +index 59c68b8aa3..e40f505e56 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ru.xlf ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ru.xlf +@@ -37,6 +37,11 @@ + Invalid value '{0}'. Valid values are 'on' and 'off'. + {Locked="on"}{Locked="off"} + ++ ++ Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. ++ Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. ++ ++ + + The test failed without providing a failure message. + The test failed without providing a failure message. +@@ -47,6 +52,26 @@ + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + + ++ ++ Slow test: {0} ++ Slow test: {0} ++ {0} is the fully qualified test name. ++ ++ ++ Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"} ++ ++ ++ {0} still running after {1}s ++ {0} still running after {1}s ++ {0} is the fully qualified test name, {1} is the elapsed seconds. ++ ++ ++ The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. ++ The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. ++ ++ + + Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.tr.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.tr.xlf +index b7f2ff3860..9e0630d207 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.tr.xlf ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.tr.xlf +@@ -37,6 +37,11 @@ + Invalid value '{0}'. Valid values are 'on' and 'off'. + {Locked="on"}{Locked="off"} + ++ ++ Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. ++ Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. ++ ++ + + The test failed without providing a failure message. + The test failed without providing a failure message. +@@ -47,6 +52,26 @@ + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + + ++ ++ Slow test: {0} ++ Slow test: {0} ++ {0} is the fully qualified test name. ++ ++ ++ Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"} ++ ++ ++ {0} still running after {1}s ++ {0} still running after {1}s ++ {0} is the fully qualified test name, {1} is the elapsed seconds. ++ ++ ++ The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. ++ The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. ++ ++ + + Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.zh-Hans.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.zh-Hans.xlf +index a4e55836c2..e1156de0ef 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.zh-Hans.xlf ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.zh-Hans.xlf +@@ -37,6 +37,11 @@ + Invalid value '{0}'. Valid values are 'on' and 'off'. + {Locked="on"}{Locked="off"} + ++ ++ Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. ++ Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. ++ ++ + + The test failed without providing a failure message. + The test failed without providing a failure message. +@@ -47,6 +52,26 @@ + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + + ++ ++ Slow test: {0} ++ Slow test: {0} ++ {0} is the fully qualified test name. ++ ++ ++ Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"} ++ ++ ++ {0} still running after {1}s ++ {0} still running after {1}s ++ {0} is the fully qualified test name, {1} is the elapsed seconds. ++ ++ ++ The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. ++ The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. ++ ++ + + Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.zh-Hant.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.zh-Hant.xlf +index 648ed1cbec..7f7a144bb7 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.zh-Hant.xlf ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.zh-Hant.xlf +@@ -37,6 +37,11 @@ + Invalid value '{0}'. Valid values are 'on' and 'off'. + {Locked="on"}{Locked="off"} + ++ ++ Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. ++ Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. ++ ++ + + The test failed without providing a failure message. + The test failed without providing a failure message. +@@ -47,6 +52,26 @@ + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + + ++ ++ Slow test: {0} ++ Slow test: {0} ++ {0} is the fully qualified test name. ++ ++ ++ Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"} ++ ++ ++ {0} still running after {1}s ++ {0} still running after {1}s ++ {0} is the fully qualified test name, {1} is the elapsed seconds. ++ ++ ++ The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. ++ The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. ++ ++ + + Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsCommandLineProviderTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsCommandLineProviderTests.cs +index cfd860e41c..d9de66dffc 100644 +--- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsCommandLineProviderTests.cs ++++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsCommandLineProviderTests.cs +@@ -59,4 +59,34 @@ public async Task ValidateOptionArgumentsAsync_ReturnsInvalid_WhenStepSummaryVal + + Assert.IsFalse(validationResult.IsValid); + } ++ ++ [TestMethod] ++ public async Task ValidateOptionArgumentsAsync_ReturnsInvalid_WhenSlowTestNoticesValueIsNotOnOrOffAsync() ++ { ++ GitHubActionsCommandLineProvider provider = new(); ++ CommandLineOption option = provider.GetCommandLineOptions().Single(o => o.Name == GitHubActionsCommandLineOptions.GitHubActionsSlowTestNotices); ++ ValidationResult validationResult = await provider.ValidateOptionArgumentsAsync(option, ["maybe"]).ConfigureAwait(false); ++ ++ Assert.IsFalse(validationResult.IsValid); ++ } ++ ++ [TestMethod] ++ public async Task ValidateOptionArgumentsAsync_ReturnsInvalid_WhenSlowTestThresholdIsNotPositiveIntegerAsync() ++ { ++ GitHubActionsCommandLineProvider provider = new(); ++ CommandLineOption option = provider.GetCommandLineOptions().Single(o => o.Name == GitHubActionsCommandLineOptions.GitHubActionsSlowTestThreshold); ++ ValidationResult validationResult = await provider.ValidateOptionArgumentsAsync(option, ["0"]).ConfigureAwait(false); ++ ++ Assert.IsFalse(validationResult.IsValid); ++ } ++ ++ [TestMethod] ++ public async Task ValidateOptionArgumentsAsync_ReturnsValid_WhenSlowTestThresholdIsPositiveIntegerAsync() ++ { ++ GitHubActionsCommandLineProvider provider = new(); ++ CommandLineOption option = provider.GetCommandLineOptions().Single(o => o.Name == GitHubActionsCommandLineOptions.GitHubActionsSlowTestThreshold); ++ ValidationResult validationResult = await provider.ValidateOptionArgumentsAsync(option, ["30"]).ConfigureAwait(false); ++ ++ Assert.IsTrue(validationResult.IsValid); ++ } + } +diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsSlowTestReporterTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsSlowTestReporterTests.cs +new file mode 100644 +index 0000000000..2aba6e2dc9 +--- /dev/null ++++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsSlowTestReporterTests.cs +@@ -0,0 +1,231 @@ ++// Copyright (c) Microsoft Corporation. All rights reserved. ++// Licensed under the MIT license. See LICENSE file in the project root for full license information. ++ ++using System.Diagnostics.CodeAnalysis; ++ ++using Microsoft.Testing.Extensions.GitHubActionsReport; ++using Microsoft.Testing.Platform.CommandLine; ++using Microsoft.Testing.Platform.Extensions.Messages; ++using Microsoft.Testing.Platform.Extensions.OutputDevice; ++using Microsoft.Testing.Platform.Helpers; ++using Microsoft.Testing.Platform.Logging; ++using Microsoft.Testing.Platform.OutputDevice; ++using Microsoft.Testing.Platform.Services; ++using Microsoft.Testing.Platform.TestHost; ++ ++using Moq; ++ ++namespace Microsoft.Testing.Extensions.UnitTests; ++ ++[TestClass] ++public sealed class GitHubActionsSlowTestReporterTests ++{ ++ private static readonly DateTimeOffset Start = new(2025, 05, 16, 12, 00, 00, TimeSpan.Zero); ++ ++ [TestMethod] ++ public void BuildNoticeLine_FormatsNoticeWithEscapedTitleAndMessage() ++ { ++ string line = GitHubActionsSlowTestReporter.BuildNoticeLine("Ns.MyTest", TimeSpan.FromSeconds(75)); ++ ++ Assert.AreEqual("::notice title=Slow test%3A Ns.MyTest::Ns.MyTest still running after 75s", line); ++ } ++ ++ [TestMethod] ++ public async Task ScanOnce_AfterThreshold_EmitsSingleNoticeAsync() ++ { ++ CapturingOutputDevice outputDevice = new(); ++ GitHubActionsSlowTestReporter reporter = CreateReporter(outputDevice, githubActions: true); ++ await reporter.OnTestSessionStartingAsync(new TestSessionContextStub()).ConfigureAwait(false); ++ ++ await reporter.ConsumeAsync(null!, CreateMessage("u1", "Ns.T1", new InProgressTestNodeStateProperty()), CancellationToken.None).ConfigureAwait(false); ++ ++ // Default threshold is 60s; nothing before it. ++ await reporter.ScanOnceAsync(Start + TimeSpan.FromSeconds(30), CancellationToken.None).ConfigureAwait(false); ++ Assert.IsEmpty(outputDevice.Lines); ++ ++ await reporter.ScanOnceAsync(Start + TimeSpan.FromSeconds(90), CancellationToken.None).ConfigureAwait(false); ++ Assert.HasCount(1, outputDevice.Lines); ++ Assert.Contains("::notice", outputDevice.Lines[0]); ++ Assert.Contains("Ns.T1 still running after", outputDevice.Lines[0]); ++ } ++ ++ [TestMethod] ++ public async Task ScanOnce_UsesExponentialBackoffAsync() ++ { ++ CapturingOutputDevice outputDevice = new(); ++ GitHubActionsSlowTestReporter reporter = CreateReporter(outputDevice, githubActions: true); ++ await reporter.OnTestSessionStartingAsync(new TestSessionContextStub()).ConfigureAwait(false); ++ await reporter.ConsumeAsync(null!, CreateMessage("u1", "Ns.T1", new InProgressTestNodeStateProperty()), CancellationToken.None).ConfigureAwait(false); ++ ++ // First emit at >= 60s; threshold then doubles to 120s, so 90s does not emit again. ++ await reporter.ScanOnceAsync(Start + TimeSpan.FromSeconds(70), CancellationToken.None).ConfigureAwait(false); ++ await reporter.ScanOnceAsync(Start + TimeSpan.FromSeconds(90), CancellationToken.None).ConfigureAwait(false); ++ Assert.HasCount(1, outputDevice.Lines); ++ ++ await reporter.ScanOnceAsync(Start + TimeSpan.FromSeconds(130), CancellationToken.None).ConfigureAwait(false); ++ Assert.HasCount(2, outputDevice.Lines); ++ } ++ ++ [TestMethod] ++ public async Task ConsumeAsync_TerminalState_StopsTrackingAsync() ++ { ++ CapturingOutputDevice outputDevice = new(); ++ GitHubActionsSlowTestReporter reporter = CreateReporter(outputDevice, githubActions: true); ++ await reporter.OnTestSessionStartingAsync(new TestSessionContextStub()).ConfigureAwait(false); ++ ++ await reporter.ConsumeAsync(null!, CreateMessage("u1", "Ns.T1", new InProgressTestNodeStateProperty()), CancellationToken.None).ConfigureAwait(false); ++ await reporter.ConsumeAsync(null!, CreateMessage("u1", "Ns.T1", new PassedTestNodeStateProperty()), CancellationToken.None).ConfigureAwait(false); ++ ++ await reporter.ScanOnceAsync(Start + TimeSpan.FromSeconds(120), CancellationToken.None).ConfigureAwait(false); ++ Assert.IsEmpty(outputDevice.Lines); ++ } ++ ++ [TestMethod] ++ public async Task ScanOnce_WhenDisabled_DoesNotEmitAsync() ++ { ++ CapturingOutputDevice outputDevice = new(); ++ GitHubActionsSlowTestReporter reporter = CreateReporter(outputDevice, githubActions: false); ++ await reporter.OnTestSessionStartingAsync(new TestSessionContextStub()).ConfigureAwait(false); ++ ++ await reporter.ConsumeAsync(null!, CreateMessage("u1", "Ns.T1", new InProgressTestNodeStateProperty()), CancellationToken.None).ConfigureAwait(false); ++ await reporter.ScanOnceAsync(Start + TimeSpan.FromSeconds(120), CancellationToken.None).ConfigureAwait(false); ++ ++ Assert.IsEmpty(outputDevice.Lines); ++ } ++ ++ [TestMethod] ++ public async Task ScanOnce_RespectsConfiguredThresholdAsync() ++ { ++ CapturingOutputDevice outputDevice = new(); ++ Dictionary options = new(StringComparer.OrdinalIgnoreCase) ++ { ++ [GitHubActionsCommandLineOptions.GitHubActionsSlowTestThreshold] = ["2"], ++ }; ++ GitHubActionsSlowTestReporter reporter = CreateReporter(outputDevice, githubActions: true, options); ++ await reporter.OnTestSessionStartingAsync(new TestSessionContextStub()).ConfigureAwait(false); ++ await reporter.ConsumeAsync(null!, CreateMessage("u1", "Ns.T1", new InProgressTestNodeStateProperty()), CancellationToken.None).ConfigureAwait(false); ++ ++ await reporter.ScanOnceAsync(Start + TimeSpan.FromSeconds(1), CancellationToken.None).ConfigureAwait(false); ++ Assert.IsEmpty(outputDevice.Lines); ++ ++ await reporter.ScanOnceAsync(Start + TimeSpan.FromSeconds(3), CancellationToken.None).ConfigureAwait(false); ++ Assert.HasCount(1, outputDevice.Lines); ++ } ++ ++ private static GitHubActionsSlowTestReporter CreateReporter(CapturingOutputDevice outputDevice, bool githubActions, Dictionary? options = null) ++ { ++ Mock environmentMock = new(); ++ environmentMock.Setup(x => x.GetEnvironmentVariable("GITHUB_ACTIONS")).Returns(githubActions ? "true" : null); ++ ++ return new GitHubActionsSlowTestReporter( ++ new FakeCommandLineOptions(options ?? new Dictionary(StringComparer.OrdinalIgnoreCase)), ++ environmentMock.Object, ++ outputDevice, ++ new NonRunningTask(), ++ new FixedClock(Start), ++ new StubLoggerFactory()); ++ } ++ ++ private static TestNodeUpdateMessage CreateMessage(string uid, string fullyQualifiedName, TestNodeStateProperty state) ++ { ++ PropertyBag propertyBag = new(); ++ propertyBag.Add(state); ++ propertyBag.Add(new SerializableKeyValuePairStringProperty("vstest.TestCase.FullyQualifiedName", fullyQualifiedName)); ++ ++ return new TestNodeUpdateMessage(new SessionUid("session"), new TestNode ++ { ++ Uid = uid, ++ DisplayName = fullyQualifiedName, ++ Properties = propertyBag, ++ }); ++ } ++ ++ private sealed class FakeCommandLineOptions(IReadOnlyDictionary options) : ICommandLineOptions ++ { ++ private readonly IReadOnlyDictionary _options = options; ++ ++ public bool IsOptionSet(string optionName) ++ => _options.ContainsKey(optionName); ++ ++ public bool TryGetOptionArgumentList(string optionName, [NotNullWhen(true)] out string[]? arguments) ++ { ++ if (_options.TryGetValue(optionName, out string[]? values)) ++ { ++ arguments = values; ++ return true; ++ } ++ ++ arguments = null; ++ return false; ++ } ++ } ++ ++ private sealed class CapturingOutputDevice : IOutputDevice ++ { ++ public List Lines { get; } = []; ++ ++ public Task DisplayAsync(IOutputDeviceDataProducer producer, IOutputDeviceData data, CancellationToken cancellationToken) ++ { ++ Lines.Add(((TextOutputDeviceData)data).Text); ++ return Task.CompletedTask; ++ } ++ } ++ ++ private sealed class FixedClock(DateTimeOffset now) : IClock ++ { ++ public DateTimeOffset UtcNow { get; } = now; ++ } ++ ++ private sealed class NonRunningTask : ITask ++ { ++ public Task Delay(int millisecondDelay) ++ => Task.CompletedTask; ++ ++ public Task Delay(TimeSpan timeSpan, CancellationToken cancellationToken) ++ => Task.CompletedTask; ++ ++ public Task Run(Action action) ++ { ++ action(); ++ return Task.CompletedTask; ++ } ++ ++ public Task Run(Func function, CancellationToken cancellationToken) ++ => function(); ++ ++ public Task Run(Func?> function, CancellationToken cancellationToken) ++ => function()!; ++ ++ public Task RunLongRunning(Func action, string name, CancellationToken cancellationToken) ++ => Task.CompletedTask; ++ ++ public Task WhenAll(params Task[] tasks) ++ => Task.WhenAll(tasks); ++ } ++ ++ private sealed class TestSessionContextStub : ITestSessionContext ++ { ++ public SessionUid SessionUid { get; } = new("session"); ++ ++ public CancellationToken CancellationToken { get; } = CancellationToken.None; ++ } ++ ++ private sealed class StubLoggerFactory : ILoggerFactory ++ { ++ public ILogger CreateLogger(string categoryName) ++ => new NullLogger(); ++ ++ private sealed class NullLogger : ILogger ++ { ++ public bool IsEnabled(LogLevel logLevel) ++ => false; ++ ++ public void Log(LogLevel logLevel, TState state, Exception? exception, Func formatter) ++ { ++ } ++ ++ public Task LogAsync(LogLevel logLevel, TState state, Exception? exception, Func formatter) ++ => Task.CompletedTask; ++ } ++ } ++} + +From 7415c87eed1053c4c0ded078c816eea47b4a0512 Mon Sep 17 00:00:00 2001 +From: Azat Mukhametshin +Date: Mon, 29 Jun 2026 21:02:22 +0200 +Subject: [PATCH 06/32] Document new GitHub Actions report features and options + +Update PACKAGE.md to describe failure annotations, the job summary, and +slow-test notices alongside log groups, with a table of all --report-gh +options. Minor test cleanup. + +Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> +--- + .../PACKAGE.md | 14 +++++++++++++- + .../GitHubActionsSlowTestReporterTests.cs | 2 +- + 2 files changed, 14 insertions(+), 2 deletions(-) + +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/PACKAGE.md b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/PACKAGE.md +index dda3f30429..4519828b44 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/PACKAGE.md ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/PACKAGE.md +@@ -15,9 +15,21 @@ dotnet add package Microsoft.Testing.Extensions.GitHubActionsReport + This package extends Microsoft.Testing.Platform with: + + - **Per-assembly log groups**: emits `::group::` / `::endgroup::` workflow commands so each test assembly's output is collapsed by default in the runner UI ++- **Failure annotations**: emits an `::error` workflow command for each failing test so failures appear in the workflow Annotations tab and, when the source location can be resolved, on the pull request's "Files changed" diff gutter ++- **Job summary**: appends a markdown roll-up (totals, failures, slowest tests) to the file pointed to by `GITHUB_STEP_SUMMARY`, which GitHub renders on the workflow run summary page ++- **Slow-test notices**: emits a `::notice` workflow command for any test still running past a threshold (default 60 seconds) + - **CI auto-detection**: detects GitHub Actions environments through the `GITHUB_ACTIONS` variable + +-Log groups are enabled by default when running on GitHub Actions. Turn them off with `--report-gh-groups off`. ++All features are enabled by default when running on GitHub Actions (or when the `--report-gh` master switch is set), and no-op otherwise. Each feature can be toggled individually: ++ ++| Option | Description | Default | ++|---|---|---| ++| `--report-gh` | Master switch to enable the extension outside GitHub Actions | off | ++| `--report-gh-groups on\|off` | Per-assembly log groups | on | ++| `--report-gh-annotations on\|off` | Failure annotations | on | ++| `--report-gh-step-summary on\|off` | Markdown job summary | on | ++| `--report-gh-slow-test-notices on\|off` | Slow-test notices | on | ++| `--report-gh-slow-test-threshold ` | Seconds before a slow-test notice is emitted | 60 | + + ## Related packages + +diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsSlowTestReporterTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsSlowTestReporterTests.cs +index 2aba6e2dc9..e55f229ae5 100644 +--- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsSlowTestReporterTests.cs ++++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsSlowTestReporterTests.cs +@@ -118,7 +118,7 @@ private static GitHubActionsSlowTestReporter CreateReporter(CapturingOutputDevic + environmentMock.Setup(x => x.GetEnvironmentVariable("GITHUB_ACTIONS")).Returns(githubActions ? "true" : null); + + return new GitHubActionsSlowTestReporter( +- new FakeCommandLineOptions(options ?? new Dictionary(StringComparer.OrdinalIgnoreCase)), ++ new FakeCommandLineOptions(options ?? []), + environmentMock.Object, + outputDevice, + new NonRunningTask(), + +From 545e3abbec7e5641b38ed3e31e6899a9d6ed0459 Mon Sep 17 00:00:00 2001 +From: Azat Mukhametshin +Date: Tue, 30 Jun 2026 18:04:59 +0200 +Subject: [PATCH 07/32] Fix GitHub Actions failure annotations dropped under CI + ANSI mode + +In CI the terminal output device uses SimpleAnsi mode and emits a color +reset ('ESC[m') without a trailing newline after the colored failed-test +block. The failure annotation, emitted immediately after via the same +output device, inherited that dangling reset as a line prefix ('ESC[m::error +...'). GitHub only recognizes a workflow command when the line begins with +'::', so the annotation was silently dropped (groups and notices are emitted +at clean line boundaries and were unaffected). + +Emit the annotation on a fresh line so '::error' always starts at column 0. +Verified end-to-end via the gh-report-validation harness: the failure +annotation now surfaces in the GitHub Actions Annotations panel and on the +file's diff gutter. + +Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> +--- + .../GitHubActionsAnnotationReporter.cs | 8 +++++++- + 1 file changed, 7 insertions(+), 1 deletion(-) + +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsAnnotationReporter.cs b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsAnnotationReporter.cs +index d741edcdb1..46d7fb8cc7 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsAnnotationReporter.cs ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsAnnotationReporter.cs +@@ -119,7 +119,13 @@ private async Task WriteAnnotationAsync(string testDisplayName, string testName, + _logger.LogTrace($"Showing failure annotation '{line}'."); + } + +- await _outputDisplay.DisplayAsync(this, new FormattedTextOutputDeviceData(line), cancellationToken).ConfigureAwait(false); ++ // Prepend a newline so the '::error' workflow command always starts at column 0 on its own line. ++ // In CI the terminal output device runs in SimpleAnsi mode and emits a color reset ('\e[m') WITHOUT a ++ // trailing newline after the preceding colored "failed" test block. Emitting the annotation directly ++ // would yield "\e[m::error ..." and GitHub only recognizes a workflow command when the line begins ++ // with '::', so the dangling reset would silently drop the annotation. The leading newline pushes the ++ // reset onto its own (ignored) line and keeps the annotation parseable. ++ await _outputDisplay.DisplayAsync(this, new FormattedTextOutputDeviceData($"\n{line}"), cancellationToken).ConfigureAwait(false); + } + + internal static /* for testing */ string GetErrorAnnotation(string testName, string? explanation, Exception? exception, string? repoRoot, IFileSystem fileSystem, ILogger logger) + +From 92e48429833c9372a09bb9a94164c373335f6281 Mon Sep 17 00:00:00 2001 +From: Azat Mukhametshin +Date: Tue, 30 Jun 2026 19:21:01 +0200 +Subject: [PATCH 08/32] Remove duplicate IncludeBuildOutput property in + MSTest.Sdk.csproj + +The property was set twice in the same PropertyGroup; keep the single +copy with the descriptive comment. + +Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> +--- + src/Package/MSTest.Sdk/MSTest.Sdk.csproj | 2 -- + 1 file changed, 2 deletions(-) + +diff --git a/src/Package/MSTest.Sdk/MSTest.Sdk.csproj b/src/Package/MSTest.Sdk/MSTest.Sdk.csproj +index 2c63824c3b..96df9602d8 100644 +--- a/src/Package/MSTest.Sdk/MSTest.Sdk.csproj ++++ b/src/Package/MSTest.Sdk/MSTest.Sdk.csproj +@@ -18,8 +18,6 @@ + true + + $(NoWarn);NU5128 +- +- false + + + + +From 8a0c93962461e4915b4438165d3096cc3394c06a Mon Sep 17 00:00:00 2001 +From: Azat Mukhametshin +Date: Wed, 1 Jul 2026 12:44:40 +0200 +Subject: [PATCH 09/32] nitpicks + +--- + .../GitHubActionsSummaryReporter.cs | 4 ++-- + .../TargetFrameworkMonikerHelper.cs | 2 +- + 2 files changed, 3 insertions(+), 3 deletions(-) + +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsSummaryReporter.cs b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsSummaryReporter.cs +index dd74765e28..7295b1731a 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsSummaryReporter.cs ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsSummaryReporter.cs +@@ -1,4 +1,4 @@ +-// Copyright (c) Microsoft Corporation. All rights reserved. ++// Copyright (c) Microsoft Corporation. All rights reserved. + // Licensed under the MIT license. See LICENSE file in the project root for full license information. + + using Microsoft.Testing.Extensions.GitHubActionsReport.Resources; +@@ -174,7 +174,7 @@ public async Task OnTestSessionFinishingAsync(ITestSessionContext testSessionCon + snapshot = [.. _records.Values]; + } + +- string markdown = BuildMarkdown(snapshot, _testApplicationModuleInfo.TryGetAssemblyName() ?? "unknown", _targetFrameworkMoniker.Value); ++ string markdown = BuildMarkdown(snapshot, _testApplicationModuleInfo.TryGetAssemblyName() ?? "unknown assembly name", _targetFrameworkMoniker.Value); + + try + { +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/TargetFrameworkMonikerHelper.cs b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/TargetFrameworkMonikerHelper.cs +index 1de5dbc640..1446bf3aa4 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/TargetFrameworkMonikerHelper.cs ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/TargetFrameworkMonikerHelper.cs +@@ -10,5 +10,5 @@ internal static class TargetFrameworkMonikerHelper + public static string GetTargetFrameworkMoniker() + => TargetFrameworkParser.GetShortTargetFramework(Assembly.GetEntryAssembly()?.GetCustomAttribute()?.FrameworkDisplayName) + ?? TargetFrameworkParser.GetShortTargetFramework(System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription) +- ?? "unknown"; ++ ?? "unknown framework"; + } + +From 51acebc3d9444ef861c555ffed3ef211424c8464 Mon Sep 17 00:00:00 2001 +From: Azat Mukhametshin +Date: Wed, 1 Jul 2026 12:17:39 +0200 +Subject: [PATCH 10/32] Register single-hook GitHubActionsReporter directly + instead of via CompositeExtensionFactory + +The log-group GitHubActionsReporter is only registered under one host hook (AddTestSessionLifetimeHandler), so wrapping it in a CompositeExtensionFactory provided no benefit -- that factory only matters for extensions registered under multiple hooks that must share a single instance (the summary and slow-test reporters). Register it directly, matching the GitHubActionsAnnotationReporter and the AzureDevOpsReporter precedent. + +Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> +--- + .../GitHubActionsExtensions.cs | 16 +++++++--------- + 1 file changed, 7 insertions(+), 9 deletions(-) + +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsExtensions.cs b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsExtensions.cs +index c939e5859e..ea3683844a 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsExtensions.cs ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsExtensions.cs +@@ -19,14 +19,6 @@ public static class GitHubActionsExtensions + /// The test application builder. + public static void AddGitHubActionsProvider(this ITestApplicationBuilder builder) + { +- var compositeReporter = new CompositeExtensionFactory(serviceProvider => +- new GitHubActionsReporter( +- serviceProvider.GetCommandLineOptions(), +- serviceProvider.GetEnvironment(), +- serviceProvider.GetOutputDevice(), +- serviceProvider.GetTestApplicationModuleInfo(), +- serviceProvider.GetLoggerFactory())); +- + var compositeSummaryReporter = new CompositeExtensionFactory(serviceProvider => + new GitHubActionsSummaryReporter( + serviceProvider.GetCommandLineOptions(), +@@ -57,7 +49,13 @@ public static void AddGitHubActionsProvider(this ITestApplicationBuilder builder + builder.TestHost.AddTestSessionLifetimeHandler(compositeSummaryReporter); + builder.TestHost.AddDataConsumer(compositeSlowTestReporter); + builder.TestHost.AddTestSessionLifetimeHandler(compositeSlowTestReporter); +- builder.TestHost.AddTestSessionLifetimeHandler(compositeReporter); ++ builder.TestHost.AddTestSessionLifetimeHandler(serviceProvider => ++ new GitHubActionsReporter( ++ serviceProvider.GetCommandLineOptions(), ++ serviceProvider.GetEnvironment(), ++ serviceProvider.GetOutputDevice(), ++ serviceProvider.GetTestApplicationModuleInfo(), ++ serviceProvider.GetLoggerFactory())); + builder.CommandLine.AddProvider(() => new GitHubActionsCommandLineProvider()); + } + } + +From 48584793306f68dcca44bdddc1017c54f762cf50 Mon Sep 17 00:00:00 2001 +From: Azat Mukhametshin +Date: Wed, 1 Jul 2026 12:35:24 +0200 +Subject: [PATCH 11/32] Revert unrelated TestFramework.Extensions.csproj + changes + +This file's MSTest.Analyzers PackageReference + _UnprivatizeAnalyzersForPack target were an accidental leftover unrelated to the GitHub Actions extension. They duplicated the existing MSTest.Analyzers.Package ProjectReference already present upstream (which correctly flows analyzer/build assets to consumers via PrivateAssets=none ExcludeAssets=compile;runtime), and the added block was buggy (ExcludeAssets=all would leak into the nuspec and block analyzer flow). Restore the file to match microsoft/main. + +Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> +--- + .../TestFramework.Extensions.csproj | 18 +----------------- + 1 file changed, 1 insertion(+), 17 deletions(-) + +diff --git a/src/TestFramework/TestFramework.Extensions/TestFramework.Extensions.csproj b/src/TestFramework/TestFramework.Extensions/TestFramework.Extensions.csproj +index 29af4674f8..d5d61fd216 100644 +--- a/src/TestFramework/TestFramework.Extensions/TestFramework.Extensions.csproj ++++ b/src/TestFramework/TestFramework.Extensions/TestFramework.Extensions.csproj +@@ -1,4 +1,4 @@ +- ++ + + + +@@ -90,22 +90,6 @@ + + + +- +- +- +- +- +- +- +- +- +- +- + + + + +From f479bd13503825572ef758fdd630e0b8a24ebf13 Mon Sep 17 00:00:00 2001 +From: Azat Mukhametshin +Date: Wed, 1 Jul 2026 17:21:40 +0200 +Subject: [PATCH 12/32] Add GitHubActionsReport --help/--info acceptance + coverage + +Register the Microsoft.Testing.Extensions.GitHubActionsReport package in the AllExtensionsInfoTest fixture and add its six --report-gh* options to both the --help and --info expected output blocks in HelpInfoAllExtensionsTests. Verified with build.cmd -pack + the acceptance test (9/9 passing). + +Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> +--- + .../HelpInfoAllExtensionsTests.cs | 42 +++++++++++++++++++ + 1 file changed, 42 insertions(+) + +diff --git a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/HelpInfoAllExtensionsTests.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/HelpInfoAllExtensionsTests.cs +index 6678ebf796..10354569ae 100644 +--- a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/HelpInfoAllExtensionsTests.cs ++++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/HelpInfoAllExtensionsTests.cs +@@ -165,6 +165,18 @@ Enable generating a CTRF (Common Test Report Format) JSON report + The name of the generated CTRF report. May include a relative or absolute path; relative paths are resolved against the test results directory and missing directories are created. + Supports the following placeholders: {pname} (test application name), {pid} (process ID), {asm} (entry assembly name), {tfm} (target framework moniker), {time} (timestamp). + Example: MyReport_{tfm}.ctrf.json ++ --report-gh ++ Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. ++ --report-gh-annotations ++ Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ --report-gh-groups ++ Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ --report-gh-slow-test-notices ++ Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ --report-gh-slow-test-threshold ++ The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. ++ --report-gh-step-summary ++ Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + --report-html + Enable generating an HTML report + --report-html-filename +@@ -510,6 +522,35 @@ The file makes it possible to identify the tests that were running at the time o + Description: The name of the generated CTRF report. May include a relative or absolute path; relative paths are resolved against the test results directory and missing directories are created. + Supports the following placeholders: {pname} (test application name), {pid} (process ID), {asm} (entry assembly name), {tfm} (target framework moniker), {time} (timestamp). + Example: MyReport_{tfm}.ctrf.json ++ GitHubActionsCommandLineProvider ++ Name: GitHub Actions report generator ++ Version: * ++ Description: GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. ++ Options: ++ --report-gh ++ Arity: 0 ++ Hidden: False ++ Description: Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. ++ --report-gh-annotations ++ Arity: 1 ++ Hidden: False ++ Description: Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ --report-gh-groups ++ Arity: 1 ++ Hidden: False ++ Description: Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ --report-gh-slow-test-notices ++ Arity: 1 ++ Hidden: False ++ Description: Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ --report-gh-slow-test-threshold ++ Arity: 1 ++ Hidden: False ++ Description: The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. ++ --report-gh-step-summary ++ Arity: 1 ++ Hidden: False ++ Description: Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + HangDumpCommandLineProvider + Name: Hang dump + Version: * +@@ -675,6 +716,7 @@ public sealed class TestAssetFixture() : TestAssetFixtureBase() + + + ++ + + + + +From 1109a6c509e52b1cbea1031bfaef7b23b50f5e67 Mon Sep 17 00:00:00 2001 +From: Azat Mukhametshin +Date: Wed, 1 Jul 2026 18:06:42 +0200 +Subject: [PATCH 13/32] Add GitHubActionsReport to MSTest.Sdk default extension + options + +Wire Microsoft.Testing.Extensions.GitHubActionsReport into the MSTest.Sdk AllMicrosoft profile, mirroring AzureDevOpsReport: default-enable property + conditional PackageReference/PackageVersion in ClassicEngine.targets, a VSTest unsupported-option guard, and a NativeAOT unsupported warning. Extend SdkTests with a per-extension selectively-enabled row and add --report-gh to the EnableAll command line. + +Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> +--- + .../MSTest.Sdk/Sdk/Runner/ClassicEngine.targets | 11 +++++++++++ + src/Package/MSTest.Sdk/Sdk/Runner/NativeAOT.targets | 1 + + src/Package/MSTest.Sdk/Sdk/VSTest/VSTest.targets | 1 + + .../MSTest.Acceptance.IntegrationTests/SdkTests.cs | 7 ++++++- + 4 files changed, 19 insertions(+), 1 deletion(-) + +diff --git a/src/Package/MSTest.Sdk/Sdk/Runner/ClassicEngine.targets b/src/Package/MSTest.Sdk/Sdk/Runner/ClassicEngine.targets +index 3957ce9e53..34b6e750ab 100644 +--- a/src/Package/MSTest.Sdk/Sdk/Runner/ClassicEngine.targets ++++ b/src/Package/MSTest.Sdk/Sdk/Runner/ClassicEngine.targets +@@ -38,6 +38,10 @@ + true + $(MicrosoftTestingExtensionsCommonVersion) + ++ ++ true ++ $(MicrosoftTestingExtensionsCommonVersion) ++ + + true + $(MicrosoftTestingExtensionsCommonVersion) +@@ -152,6 +156,13 @@ + + ++ ++ $(MicrosoftTestingExtensionsGitHubActionsReportVersion) ++ ++ ++ + + $(MicrosoftTestingExtensionsCtrfReportVersion) +diff --git a/src/Package/MSTest.Sdk/Sdk/Runner/NativeAOT.targets b/src/Package/MSTest.Sdk/Sdk/Runner/NativeAOT.targets +index f61b50710c..58f85b8816 100644 +--- a/src/Package/MSTest.Sdk/Sdk/Runner/NativeAOT.targets ++++ b/src/Package/MSTest.Sdk/Sdk/Runner/NativeAOT.targets +@@ -25,6 +25,7 @@ + + + ++ + + + +diff --git a/src/Package/MSTest.Sdk/Sdk/VSTest/VSTest.targets b/src/Package/MSTest.Sdk/Sdk/VSTest/VSTest.targets +index dcc6680431..3a7a3bd8b6 100644 +--- a/src/Package/MSTest.Sdk/Sdk/VSTest/VSTest.targets ++++ b/src/Package/MSTest.Sdk/Sdk/VSTest/VSTest.targets +@@ -6,6 +6,7 @@ + + + ++ + + + +diff --git a/test/IntegrationTests/MSTest.Acceptance.IntegrationTests/SdkTests.cs b/test/IntegrationTests/MSTest.Acceptance.IntegrationTests/SdkTests.cs +index 4bbac6557f..ad1da12473 100644 +--- a/test/IntegrationTests/MSTest.Acceptance.IntegrationTests/SdkTests.cs ++++ b/test/IntegrationTests/MSTest.Acceptance.IntegrationTests/SdkTests.cs +@@ -201,6 +201,11 @@ public async Task RunTests_With_CentralPackageManagement_Standalone(string multi + "--report-azdo", + "--crashdump")); + ++ yield return new((buildConfig.MultiTfm, buildConfig.BuildConfiguration, ++ "true", ++ "--report-gh", ++ "--crashdump")); ++ + yield return new((buildConfig.MultiTfm, buildConfig.BuildConfiguration, + "true", + "--report-ctrf", +@@ -269,7 +274,7 @@ public async Task RunTests_With_MSTestRunner_Standalone_EnableAll_Extensions(str + foreach (string tfm in multiTfm.Split(";")) + { + var testHost = TestHost.LocateFrom(testAsset.TargetAssetPath, AssetName, tfm, buildConfiguration: buildConfiguration); +- TestHostResult testHostResult = await testHost.ExecuteAsync(command: "--coverage --retry-failed-tests 3 --report-trx --crashdump --hangdump --report-azdo --report-html", cancellationToken: TestContext.CancellationToken); ++ TestHostResult testHostResult = await testHost.ExecuteAsync(command: "--coverage --retry-failed-tests 3 --report-trx --crashdump --hangdump --report-azdo --report-gh --report-html", cancellationToken: TestContext.CancellationToken); + testHostResult.AssertOutputContainsSummary(0, 1, 0); + } + } + +From 2c0ef0cca305ff663fbb3e8c75f02db97254d56e Mon Sep 17 00:00:00 2001 +From: Azat Mukhametshin +Date: Wed, 1 Jul 2026 18:07:16 +0200 +Subject: [PATCH 14/32] Consolidate TargetFrameworkMonikerHelper and + TestNodeIdentity into shared helpers + +The GitHubActionsReport extension carried its own copies of TargetFrameworkMonikerHelper and TestNodeIdentity that duplicated logic already present in (or alongside) SharedExtensionHelpers. Merge them into src/Platform/SharedExtensionHelpers so both AzureDevOpsReport and GitHubActionsReport share one implementation: + +- TargetFrameworkMonikerHelper: the two divergent methods are merged under distinct names -- GetTargetFrameworkMonikerIncludingPlatform (existing shared behavior) and GetTargetFrameworkMonikerWithRuntimeFallback (former GitHub behavior); all call sites repointed with behavior preserved. + +- TestNodeIdentity: the two copies were identical, so they collapse to a single shared GetTestName; the AzureDevOpsReport copy is moved to the shared folder and the GitHub copy deleted, with the shared file compile-linked into both extensions. + +Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> +--- + .../AzureDevOpsArtifactUploader.cs | 2 +- + .../AzureDevOpsLogGroupReporter.cs | 2 +- + .../AzureDevOpsReporter.cs | 2 +- + .../AzureDevOpsSummaryReporter.cs | 2 +- + ...evOpsTestResultsPublisher.Configuration.cs | 2 +- + ...esting.Extensions.AzureDevOpsReport.csproj | 1 + + .../CtrfReportEngine.FileNaming.cs | 2 +- + .../GitHubActionsReporter.cs | 2 +- + .../GitHubActionsSummaryReporter.cs | 2 +- + ...ting.Extensions.GitHubActionsReport.csproj | 2 ++ + .../TargetFrameworkMonikerHelper.cs | 14 --------- + .../TestNodeIdentity.cs | 30 ------------------- + .../ReportEngineBase.cs | 2 +- + .../TargetFrameworkMonikerHelper.cs | 11 +++++-- + .../TestNodeIdentity.cs | 7 ++--- + 15 files changed, 23 insertions(+), 60 deletions(-) + delete mode 100644 src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/TargetFrameworkMonikerHelper.cs + delete mode 100644 src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/TestNodeIdentity.cs + rename src/Platform/{Microsoft.Testing.Extensions.AzureDevOpsReport => SharedExtensionHelpers}/TestNodeIdentity.cs (78%) + +diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsArtifactUploader.cs b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsArtifactUploader.cs +index 2cef8734c2..6e941151ca 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsArtifactUploader.cs ++++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsArtifactUploader.cs +@@ -69,7 +69,7 @@ public AzureDevOpsArtifactUploader( + && artifactNameArguments is [string artifactName] + ? artifactName + : null; +- _targetFrameworkMoniker = new(TargetFrameworkMonikerHelper.GetTargetFrameworkMoniker); ++ _targetFrameworkMoniker = new(TargetFrameworkMonikerHelper.GetTargetFrameworkMonikerIncludingPlatform); + } + + public Type[] DataTypesConsumed { get; } = [typeof(TestNodeUpdateMessage), typeof(FileArtifact)]; +diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsLogGroupReporter.cs b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsLogGroupReporter.cs +index 8f6521b2f7..4f31a79f1a 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsLogGroupReporter.cs ++++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsLogGroupReporter.cs +@@ -53,7 +53,7 @@ public AzureDevOpsLogGroupReporter( + _outputDevice = outputDevice; + _testApplicationModuleInfo = testApplicationModuleInfo; + _logger = loggerFactory.CreateLogger(); +- _targetFrameworkMoniker = new(TargetFrameworkMonikerHelper.GetTargetFrameworkMoniker); ++ _targetFrameworkMoniker = new(TargetFrameworkMonikerHelper.GetTargetFrameworkMonikerIncludingPlatform); + } + + public string Uid => nameof(AzureDevOpsLogGroupReporter); +diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsReporter.cs b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsReporter.cs +index 43c7f0421b..2731db0b0c 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsReporter.cs ++++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsReporter.cs +@@ -69,7 +69,7 @@ public AzureDevOpsReporter( + _outputDisplay = outputDisplay; + _historyService = historyService; + _logger = loggerFactory.CreateLogger(); +- _targetFrameworkMoniker = TargetFrameworkMonikerHelper.GetTargetFrameworkMoniker(); ++ _targetFrameworkMoniker = TargetFrameworkMonikerHelper.GetTargetFrameworkMonikerIncludingPlatform(); + } + + public Type[] DataTypesConsumed { get; } = +diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsSummaryReporter.cs b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsSummaryReporter.cs +index 127b9e1e41..fb0afad0f5 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsSummaryReporter.cs ++++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsSummaryReporter.cs +@@ -63,7 +63,7 @@ public AzureDevOpsSummaryReporter( + _testApplicationModuleInfo = testApplicationModuleInfo; + _logger = loggerFactory.CreateLogger(); + _isEnabled = commandLineOptions.IsOptionSet(AzureDevOpsCommandLineOptions.AzureDevOpsSummary); +- _targetFrameworkMoniker = new(TargetFrameworkMonikerHelper.GetTargetFrameworkMoniker); ++ _targetFrameworkMoniker = new(TargetFrameworkMonikerHelper.GetTargetFrameworkMonikerIncludingPlatform); + } + + public Type[] DataTypesConsumed { get; } = [typeof(TestNodeUpdateMessage)]; +diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsTestResultsPublisher.Configuration.cs b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsTestResultsPublisher.Configuration.cs +index f90604e41b..a3a8fa7da8 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsTestResultsPublisher.Configuration.cs ++++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsTestResultsPublisher.Configuration.cs +@@ -43,7 +43,7 @@ private bool TryCreatePublishConfiguration(out AzureDevOpsPublishConfiguration? + string currentTestApplicationPath = _testApplicationModuleInfo.GetCurrentTestApplicationFullPath(); + string assemblyName = _testApplicationModuleInfo.TryGetAssemblyName() ?? Path.GetFileNameWithoutExtension(currentTestApplicationPath); + string automatedTestStorage = Path.GetFileNameWithoutExtension(currentTestApplicationPath); +- string targetFrameworkMoniker = TargetFrameworkMonikerHelper.GetTargetFrameworkMoniker(); ++ string targetFrameworkMoniker = TargetFrameworkMonikerHelper.GetTargetFrameworkMonikerIncludingPlatform(); + string agentName = _environment.GetEnvironmentVariable("AGENT_NAME") ?? _environment.MachineName; + string? stageName = _environment.GetEnvironmentVariable("SYSTEM_STAGENAME"); + string? jobName = _environment.GetEnvironmentVariable("SYSTEM_JOBNAME"); +diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Microsoft.Testing.Extensions.AzureDevOpsReport.csproj b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Microsoft.Testing.Extensions.AzureDevOpsReport.csproj +index e9a3e00d87..0fac9243c2 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Microsoft.Testing.Extensions.AzureDevOpsReport.csproj ++++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Microsoft.Testing.Extensions.AzureDevOpsReport.csproj +@@ -12,6 +12,7 @@ + + + ++ + + + +diff --git a/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportEngine.FileNaming.cs b/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportEngine.FileNaming.cs +index 4e13db9e73..2427386651 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportEngine.FileNaming.cs ++++ b/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportEngine.FileNaming.cs +@@ -11,7 +11,7 @@ private string BuildDefaultFileName(DateTimeOffset finishTime) + ?? _environment.GetEnvironmentVariable("USER") + ?? "user"; + string moduleName = Path.GetFileNameWithoutExtension(_testApplicationModuleInfo.GetCurrentTestApplicationFullPath()); +- string targetFrameworkMoniker = TargetFrameworkMonikerHelper.GetTargetFrameworkMoniker(); ++ string targetFrameworkMoniker = TargetFrameworkMonikerHelper.GetTargetFrameworkMonikerIncludingPlatform(); + string raw = $"{user}_{_environment.MachineName}_{moduleName}_{targetFrameworkMoniker}_{finishTime:yyyy-MM-dd_HH_mm_ss}.ctrf.json"; + return ReplaceInvalidFileNameChars(raw); + } +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsReporter.cs b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsReporter.cs +index 3ba6f1d9c8..f3d1556848 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsReporter.cs ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsReporter.cs +@@ -35,7 +35,7 @@ public GitHubActionsReporter( + _outputDisplay = outputDisplay; + _testApplicationModuleInfo = testApplicationModuleInfo; + _logger = loggerFactory.CreateLogger(); +- _targetFrameworkMoniker = TargetFrameworkMonikerHelper.GetTargetFrameworkMoniker(); ++ _targetFrameworkMoniker = TargetFrameworkMonikerHelper.GetTargetFrameworkMonikerWithRuntimeFallback(); + } + + /// +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsSummaryReporter.cs b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsSummaryReporter.cs +index 7295b1731a..d8e6be6b39 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsSummaryReporter.cs ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsSummaryReporter.cs +@@ -61,7 +61,7 @@ public GitHubActionsSummaryReporter( + _outputDevice = outputDevice; + _testApplicationModuleInfo = testApplicationModuleInfo; + _logger = loggerFactory.CreateLogger(); +- _targetFrameworkMoniker = new(TargetFrameworkMonikerHelper.GetTargetFrameworkMoniker); ++ _targetFrameworkMoniker = new(TargetFrameworkMonikerHelper.GetTargetFrameworkMonikerWithRuntimeFallback); + _isEnabled = GitHubActionsFeature.IsEnabled(commandLineOptions, environment, GitHubActionsCommandLineOptions.GitHubActionsStepSummary); + } + +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Microsoft.Testing.Extensions.GitHubActionsReport.csproj b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Microsoft.Testing.Extensions.GitHubActionsReport.csproj +index c0334fad2a..48b90c93c7 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Microsoft.Testing.Extensions.GitHubActionsReport.csproj ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Microsoft.Testing.Extensions.GitHubActionsReport.csproj +@@ -11,6 +11,8 @@ + + + ++ ++ + + + +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/TargetFrameworkMonikerHelper.cs b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/TargetFrameworkMonikerHelper.cs +deleted file mode 100644 +index 1446bf3aa4..0000000000 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/TargetFrameworkMonikerHelper.cs ++++ /dev/null +@@ -1,14 +0,0 @@ +-// Copyright (c) Microsoft Corporation. All rights reserved. +-// Licensed under the MIT license. See LICENSE file in the project root for full license information. +- +-using Microsoft.Testing.Platform.OutputDevice; +- +-namespace Microsoft.Testing.Extensions.GitHubActionsReport; +- +-internal static class TargetFrameworkMonikerHelper +-{ +- public static string GetTargetFrameworkMoniker() +- => TargetFrameworkParser.GetShortTargetFramework(Assembly.GetEntryAssembly()?.GetCustomAttribute()?.FrameworkDisplayName) +- ?? TargetFrameworkParser.GetShortTargetFramework(System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription) +- ?? "unknown framework"; +-} +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/TestNodeIdentity.cs b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/TestNodeIdentity.cs +deleted file mode 100644 +index f6d1cd53ec..0000000000 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/TestNodeIdentity.cs ++++ /dev/null +@@ -1,30 +0,0 @@ +-// Copyright (c) Microsoft Corporation. All rights reserved. +-// Licensed under the MIT license. See LICENSE file in the project root for full license information. +- +-using Microsoft.Testing.Platform.Extensions.Messages; +- +-namespace Microsoft.Testing.Extensions.GitHubActionsReport; +- +-internal static class TestNodeIdentity +-{ +- private const string FullyQualifiedNamePropertyKey = "vstest.TestCase.FullyQualifiedName"; +- +- /// +- /// Resolves the stable, fully-qualified test name. Falls back to the display name when the +- /// fully-qualified name property is unavailable. +- /// +- public static string GetTestName(TestNode testNode) +- { +- using PropertyBag.PropertyBagEnumerator enumerator = testNode.Properties.GetStructEnumerator(); +- while (enumerator.MoveNext()) +- { +- if (enumerator.Current is SerializableKeyValuePairStringProperty kvp +- && kvp.Key == FullyQualifiedNamePropertyKey) +- { +- return kvp.Value; +- } +- } +- +- return testNode.DisplayName; +- } +-} +diff --git a/src/Platform/SharedExtensionHelpers/ReportEngineBase.cs b/src/Platform/SharedExtensionHelpers/ReportEngineBase.cs +index 52eefe194c..3f6327f7a8 100644 +--- a/src/Platform/SharedExtensionHelpers/ReportEngineBase.cs ++++ b/src/Platform/SharedExtensionHelpers/ReportEngineBase.cs +@@ -81,7 +81,7 @@ internal static string BuildDefaultFileName(string testApplicationModule, string + // TestResults folder overwrites the previous file (with a warning), matching + // the behavior of an explicitly-provided file name. + string moduleName = Path.GetFileNameWithoutExtension(testApplicationModule); +- string targetFrameworkMoniker = TargetFrameworkMonikerHelper.GetTargetFrameworkMoniker(); ++ string targetFrameworkMoniker = TargetFrameworkMonikerHelper.GetTargetFrameworkMonikerIncludingPlatform(); + string architecture = RuntimeInformation.ProcessArchitecture.ToString().ToLowerInvariant(); + string raw = $"{moduleName}_{targetFrameworkMoniker}_{architecture}.{extension}"; + return ReplaceInvalidFileNameChars(raw); +diff --git a/src/Platform/SharedExtensionHelpers/TargetFrameworkMonikerHelper.cs b/src/Platform/SharedExtensionHelpers/TargetFrameworkMonikerHelper.cs +index c17af84b8a..b64bd6f8d5 100644 +--- a/src/Platform/SharedExtensionHelpers/TargetFrameworkMonikerHelper.cs ++++ b/src/Platform/SharedExtensionHelpers/TargetFrameworkMonikerHelper.cs +@@ -1,4 +1,4 @@ +-// Copyright (c) Microsoft Corporation. All rights reserved. ++// Copyright (c) Microsoft Corporation. All rights reserved. + // Licensed under the MIT license. See LICENSE file in the project root for full license information. + + using Microsoft.Testing.Platform.OutputDevice; +@@ -7,7 +7,12 @@ namespace Microsoft.Testing.Extensions; + + internal static class TargetFrameworkMonikerHelper + { +- public static string GetTargetFrameworkMoniker() ++ public static string GetTargetFrameworkMonikerIncludingPlatform() + => TargetFrameworkParser.GetShortTargetFrameworkIncludingPlatform(Assembly.GetEntryAssembly()) +- ?? "unknown"; ++ ?? "unknown framework"; ++ ++ public static string GetTargetFrameworkMonikerWithRuntimeFallback() ++ => TargetFrameworkParser.GetShortTargetFramework(Assembly.GetEntryAssembly()?.GetCustomAttribute()?.FrameworkDisplayName) ++ ?? TargetFrameworkParser.GetShortTargetFramework(System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription) ++ ?? "unknown framework"; + } +diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/TestNodeIdentity.cs b/src/Platform/SharedExtensionHelpers/TestNodeIdentity.cs +similarity index 78% +rename from src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/TestNodeIdentity.cs +rename to src/Platform/SharedExtensionHelpers/TestNodeIdentity.cs +index 9e9f263c55..08ae456ee6 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/TestNodeIdentity.cs ++++ b/src/Platform/SharedExtensionHelpers/TestNodeIdentity.cs +@@ -3,16 +3,15 @@ + + using Microsoft.Testing.Platform.Extensions.Messages; + +-namespace Microsoft.Testing.Extensions.AzureDevOpsReport; ++namespace Microsoft.Testing.Extensions; + + internal static class TestNodeIdentity + { + private const string FullyQualifiedNamePropertyKey = "vstest.TestCase.FullyQualifiedName"; + + /// +- /// Resolves the stable test name used to match a against Azure DevOps history +- /// (which keys results by AutomatedTestName). Falls back to the display name when the +- /// fully-qualified name property is unavailable. ++ /// Resolves the stable, fully-qualified test name for a . Falls back to the ++ /// display name when the fully-qualified name property is unavailable. + /// + public static string GetTestName(TestNode testNode) + { + +From 50a96d03f84223910fdd2415bb6442585a26e5cf Mon Sep 17 00:00:00 2001 +From: Azat Mukhametshin +Date: Wed, 1 Jul 2026 19:54:30 +0200 +Subject: [PATCH 15/32] Split slow-test reporter into shared base; make GitHub + Actions API experimental and pack as 1.0.0-alpha + +Task 1 - Split slow-test reporter into shared + pipeline-specific: +- Extract the common plumbing (in-progress tracking, background scan loop, + exponential backoff, session lifecycle) into + SharedExtensionHelpers/SlowTestReporterBase.cs, linked into both the Azure + DevOps and GitHub Actions report extensions. Each host now only supplies + IsEnabled, GetTestName, threshold resolution, an optional activation gate, + and the host-specific line rendering. + +Task 2 - Make the GitHub Actions reporter API experimental: +- Mark GitHubActionsExtensions with [Experimental("TPEXP")] and flag the + matching PublicAPI entries with [TPEXP], mirroring JUnitReport. + +Task 3 - Pack Microsoft.Testing.Extensions.GitHubActionsReport as 1.0.0-alpha: +- Add version-prefix/prerelease-label properties (Directory.Build.props), + wire them through the csproj, MSTest.Sdk.csproj template properties and + Sdk.props.template, and drop the CommonVersion default in + ClassicEngine.targets, mirroring JUnitReport. + +Merge fix - Resolve TestNodeIdentity cross-assembly ambiguity introduced by +merging main (which added AzureDevOpsTestNodeIdentityTests) with the branch's +shared-namespace TestNodeIdentity consolidation: give GitHubActionsReport its +own TestNodeIdentity so the shared type is not duplicated across two +test-visible assemblies. + +Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> +--- + Directory.Build.props | 7 + + src/Package/MSTest.Sdk/MSTest.Sdk.csproj | 9 +- + .../Sdk/Runner/ClassicEngine.targets | 4 +- + src/Package/MSTest.Sdk/Sdk/Sdk.props.template | 1 + + .../AzureDevOpsSlowTestReporter.cs | 244 ++-------------- + ...esting.Extensions.AzureDevOpsReport.csproj | 1 + + .../GitHubActionsExtensions.cs | 1 + + .../GitHubActionsSlowTestReporter.cs | 212 +------------- + ...ting.Extensions.GitHubActionsReport.csproj | 5 +- + .../PublicAPI/PublicAPI.Unshipped.txt | 4 +- + .../TestNodeIdentity.cs | 57 ++++ + .../SlowTestReporterBase.cs | 266 ++++++++++++++++++ + .../AzureDevOpsTestNodeIdentityTests.cs | 1 - + 13 files changed, 387 insertions(+), 425 deletions(-) + create mode 100644 src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/TestNodeIdentity.cs + create mode 100644 src/Platform/SharedExtensionHelpers/SlowTestReporterBase.cs + +diff --git a/Directory.Build.props b/Directory.Build.props +index d043d95385..baeca8286a 100644 +--- a/Directory.Build.props ++++ b/Directory.Build.props +@@ -80,6 +80,13 @@ + --> + 1.0.0 + ++ alpha ++ ++ ++ 1.0.0 ++ + alpha + + ++ + true +- $(MicrosoftTestingExtensionsCommonVersion) + + + true +diff --git a/src/Package/MSTest.Sdk/Sdk/Sdk.props.template b/src/Package/MSTest.Sdk/Sdk/Sdk.props.template +index 004df17238..1bc238c1a9 100644 +--- a/src/Package/MSTest.Sdk/Sdk/Sdk.props.template ++++ b/src/Package/MSTest.Sdk/Sdk/Sdk.props.template +@@ -22,6 +22,7 @@ + ${MicrosoftTestingExtensionsCodeCoverageVersion} + ${MicrosoftTestingExtensionsCtrfReportVersion} + ${MicrosoftTestingExtensionsFakesVersion} ++ ${MicrosoftTestingExtensionsGitHubActionsReportVersion} + ${MicrosoftTestingExtensionsJUnitReportVersion} + ${MicrosoftTestingExtensionsOpenTelemetryVersion} + ${MicrosoftTestingPlatformVersion} +diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsSlowTestReporter.cs b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsSlowTestReporter.cs +index d3de325f48..c745c8b886 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsSlowTestReporter.cs ++++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsSlowTestReporter.cs +@@ -4,14 +4,10 @@ + using Microsoft.Testing.Extensions.AzureDevOpsReport.Resources; + using Microsoft.Testing.Extensions.Reporting; + using Microsoft.Testing.Platform.CommandLine; +-using Microsoft.Testing.Platform.Extensions; + using Microsoft.Testing.Platform.Extensions.Messages; +-using Microsoft.Testing.Platform.Extensions.OutputDevice; +-using Microsoft.Testing.Platform.Extensions.TestHost; + using Microsoft.Testing.Platform.Helpers; + using Microsoft.Testing.Platform.Logging; + using Microsoft.Testing.Platform.OutputDevice; +-using Microsoft.Testing.Platform.Services; + + namespace Microsoft.Testing.Extensions.AzureDevOpsReport; + +@@ -20,32 +16,18 @@ namespace Microsoft.Testing.Extensions.AzureDevOpsReport; + /// threshold for tests with a known-short historical runtime and decorating each emission with the historical + /// p95/p99 fetched from Azure DevOps test history. + /// +-/// +-/// This is a self-contained emitter for the Azure DevOps host. Once the platform-level IProgressEnricher +-/// hook (issue #9139) ships, the surfacing/backoff logic should migrate onto it and this type should only +-/// supply the history-driven threshold and decoration. +-/// +-internal sealed class AzureDevOpsSlowTestReporter : IDataConsumer, ITestSessionLifetimeHandler, IOutputDeviceDataProducer ++internal sealed class AzureDevOpsSlowTestReporter : SlowTestReporterBase + { + private const string AzureDevOpsTfBuildVariableName = "TF_BUILD"; +- private static readonly TimeSpan ScanInterval = TimeSpan.FromSeconds(1); + + private readonly ICommandLineOptions _commandLineOptions; + private readonly IEnvironment _environment; +- private readonly IOutputDevice _outputDevice; +- private readonly ITask _task; +- private readonly IClock _clock; +- private readonly ILogger _logger; + private readonly IAzureDevOpsHistoryService _historyService; +- private readonly ConcurrentDictionary _inProgress = new(StringComparer.Ordinal); + private readonly bool _isEnabled; + private readonly TimeSpan _staticThreshold; + + private double _multiplier; + private volatile int _minimumSampleCount; +- private volatile bool _active; +- private CancellationTokenSource? _loopCancellationTokenSource; +- private Task? _loopTask; + + public AzureDevOpsSlowTestReporter( + ICommandLineOptions commandLineOptions, +@@ -55,203 +37,61 @@ public AzureDevOpsSlowTestReporter( + IClock clock, + ILoggerFactory loggerFactory, + IAzureDevOpsHistoryService historyService) ++ : base(outputDevice, task, clock, loggerFactory.CreateLogger()) + { + _commandLineOptions = commandLineOptions; + _environment = environment; +- _outputDevice = outputDevice; +- _task = task; +- _clock = clock; +- _logger = loggerFactory.CreateLogger(); + _historyService = historyService; + _staticThreshold = TimeSpan.FromSeconds(AzureDevOpsCommandLineOptions.SlowTestStaticThresholdSeconds); + _isEnabled = commandLineOptions.IsOptionSet(AzureDevOpsCommandLineOptions.AzureDevOpsOptionName) + && commandLineOptions.IsOptionSet(AzureDevOpsCommandLineOptions.AzureDevOpsSlowTestHistory); + } + +- public Type[] DataTypesConsumed { get; } = [typeof(TestNodeUpdateMessage)]; ++ public override string Uid => nameof(AzureDevOpsSlowTestReporter); + +- public string Uid => nameof(AzureDevOpsSlowTestReporter); ++ public override string DisplayName => AzureDevOpsResources.DisplayName; + +- public string Version => ExtensionVersion.DefaultSemVer; ++ public override string Description => AzureDevOpsResources.Description; + +- public string DisplayName => AzureDevOpsResources.DisplayName; ++ protected override bool IsEnabled => _isEnabled; + +- public string Description => AzureDevOpsResources.Description; +- +- public Task IsEnabledAsync() => Task.FromResult(_isEnabled); +- +- public async Task OnTestSessionStartingAsync(ITestSessionContext testSessionContext) ++ protected override bool OnActivating() + { +- try ++ if (!string.Equals(_environment.GetEnvironmentVariable(AzureDevOpsTfBuildVariableName), "true", StringComparison.OrdinalIgnoreCase)) + { +- testSessionContext.CancellationToken.ThrowIfCancellationRequested(); +- +- _active = false; +- _inProgress.Clear(); +- +- if (!_isEnabled) ++ // Outside Azure DevOps the feature truly no-ops: we only leave a low-noise trace and never ++ // surface an output-device line, so local/dev runs that happen to pass the option stay quiet. ++ if (Logger.IsEnabled(LogLevel.Trace)) + { +- return; ++ Logger.LogTrace(AzureDevOpsResources.SlowTestHistoryRequiresTfBuildWarning); + } + +- if (!string.Equals(_environment.GetEnvironmentVariable(AzureDevOpsTfBuildVariableName), "true", StringComparison.OrdinalIgnoreCase)) +- { +- // Outside Azure DevOps the feature truly no-ops: we only leave a low-noise trace and never +- // surface an output-device line, so local/dev runs that happen to pass the option stay quiet. +- if (_logger.IsEnabled(LogLevel.Trace)) +- { +- _logger.LogTrace(AzureDevOpsResources.SlowTestHistoryRequiresTfBuildWarning); +- } +- +- return; +- } +- +- // 'double' cannot be marked 'volatile', so publish the multiplier through Volatile.Write; the +- // remaining fields use the 'volatile' modifier. Writing _active = true last (below) acts as the +- // release fence that publishes all three to the test-data-producer threads in ConsumeAsync. +- Volatile.Write(ref _multiplier, GetMultiplier()); +- _minimumSampleCount = GetMinimumSampleCount(); +- +- _loopCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(testSessionContext.CancellationToken); +- _active = true; +- _loopTask = _task.RunLongRunning(() => ScanLoopAsync(_loopCancellationTokenSource.Token), nameof(AzureDevOpsSlowTestReporter), _loopCancellationTokenSource.Token); +- } +- catch (OperationCanceledException) +- { +- throw; ++ return false; + } +- catch (Exception ex) +- { +- LogUnexpectedException(nameof(OnTestSessionStartingAsync), ex); +- } +- } +- +- public Task ConsumeAsync(IDataProducer dataProducer, IData value, CancellationToken cancellationToken) +- { +- try +- { +- cancellationToken.ThrowIfCancellationRequested(); + +- if (!_active || value is not TestNodeUpdateMessage update) +- { +- return Task.CompletedTask; +- } +- +- string uid = update.TestNode.Uid; +- TestNodeStateProperty? state = update.TestNode.Properties.SingleOrDefault(); +- if (state is InProgressTestNodeStateProperty) +- { +- string testName = TestNodeIdentity.GetTestName(update.TestNode); +- TimeSpan threshold = ResolveThreshold(testName); +- _inProgress[uid] = new InProgressTest(testName, _clock.UtcNow, threshold); +- } +- else if (state is not null) +- { +- // Any non-in-progress state (passed/failed/skipped/error/timeout/cancelled) is terminal for surfacing. +- _inProgress.TryRemove(uid, out _); +- } +- } +- catch (OperationCanceledException) +- { +- throw; +- } +- catch (Exception ex) +- { +- LogUnexpectedException(nameof(ConsumeAsync), ex); +- } ++ // 'double' cannot be marked 'volatile', so publish the multiplier through Volatile.Write; the ++ // remaining fields use the 'volatile' modifier. The base class writes _active = true last, which ++ // acts as the release fence that publishes all three to the test-data-producer threads in ConsumeAsync. ++ Volatile.Write(ref _multiplier, GetMultiplier()); ++ _minimumSampleCount = GetMinimumSampleCount(); + +- return Task.CompletedTask; ++ return true; + } + +- public async Task OnTestSessionFinishingAsync(ITestSessionContext testSessionContext) +- { +- _active = false; +- +- CancellationTokenSource? loopCancellationTokenSource = _loopCancellationTokenSource; +- if (loopCancellationTokenSource is not null) +- { +-#pragma warning disable VSTHRD103 // CancelAsync is unavailable on all target frameworks. +- loopCancellationTokenSource.Cancel(); +-#pragma warning restore VSTHRD103 +- } +- +- if (_loopTask is not null) +- { +- try +- { +- await _loopTask.ConfigureAwait(false); +- } +- catch (OperationCanceledException) +- { +- // Expected during normal shutdown: cancelling _loopCancellationTokenSource above unblocks the +- // scan loop, which surfaces as a cancellation here. Nothing to do — swallow and finish teardown. +- } +- catch (Exception ex) +- { +- LogUnexpectedException(nameof(OnTestSessionFinishingAsync), ex); +- } +- } ++ protected override string GetTestName(TestNode testNode) => TestNodeIdentity.GetTestName(testNode); + +- loopCancellationTokenSource?.Dispose(); +- _loopCancellationTokenSource = null; +- _loopTask = null; +- _inProgress.Clear(); +- } +- +- private async Task ScanLoopAsync(CancellationToken cancellationToken) +- { +- while (!cancellationToken.IsCancellationRequested) +- { +- try +- { +- await _task.Delay(ScanInterval, cancellationToken).ConfigureAwait(false); +- await ScanOnceAsync(_clock.UtcNow, cancellationToken).ConfigureAwait(false); +- } +- catch (OperationCanceledException) +- { +- return; +- } +- } +- } +- +- // Internal for unit testing: performs a single surfacing pass at the given 'now' so tests can drive +- // the emission/backoff logic deterministically without relying on the timer-driven loop. +- internal async Task ScanOnceAsync(DateTimeOffset now, CancellationToken cancellationToken) ++ protected override TimeSpan ResolveThreshold(string testName) + { +- foreach (KeyValuePair entry in _inProgress) +- { +- InProgressTest test = entry.Value; +- TimeSpan elapsed = now - test.StartTime; +- if (elapsed < test.NextEmitThreshold) +- { +- continue; +- } +- +- // Exponential backoff so a genuinely stuck test does not spam the log: T, 2T, 4T, ... +- // Clamp at TimeSpan.MaxValue so a very long-running test cannot overflow Ticks * 2 into a +- // negative value (which would make the backoff fire on every scan). +- long currentTicks = test.NextEmitThreshold.Ticks; +- test.NextEmitThreshold = currentTicks > TimeSpan.MaxValue.Ticks / 2 +- ? TimeSpan.MaxValue +- : TimeSpan.FromTicks(currentTicks * 2); +- +- try +- { +- await EmitSlowTestAsync(test, elapsed, cancellationToken).ConfigureAwait(false); +- } +- catch (Exception ex) when (ex is not OperationCanceledException) +- { +- LogUnexpectedException(nameof(ScanOnceAsync), ex); +- } +- } ++ bool hasStats = _historyService.TryGetDurationStats(testName, out DurationHistoryStats stats); ++ return AzureDevOpsSlowTestThresholds.ComputeThreshold(_staticThreshold, stats, hasStats, Volatile.Read(ref _multiplier), _minimumSampleCount); + } + +- private async Task EmitSlowTestAsync(InProgressTest test, TimeSpan elapsed, CancellationToken cancellationToken) ++ protected override Task EmitSlowTestAsync(string testName, TimeSpan elapsed, CancellationToken cancellationToken) + { + string elapsedText = AzureDevOpsSlowTestThresholds.FormatDuration(elapsed.TotalMilliseconds); +- string line = string.Format(CultureInfo.InvariantCulture, AzureDevOpsResources.SlowTestStillRunning, elapsedText, test.TestName); ++ string line = string.Format(CultureInfo.InvariantCulture, AzureDevOpsResources.SlowTestStillRunning, elapsedText, testName); + +- if (_historyService.TryGetDurationStats(test.TestName, out DurationHistoryStats stats) ++ if (_historyService.TryGetDurationStats(testName, out DurationHistoryStats stats) + && AzureDevOpsSlowTestThresholds.HasUsableHistory(stats, hasStats: true, _minimumSampleCount)) + { + string decoration = string.Format( +@@ -263,13 +103,7 @@ private async Task EmitSlowTestAsync(InProgressTest test, TimeSpan elapsed, Canc + line = $"{line} {decoration}"; + } + +- await _outputDevice.DisplayAsync(this, new AzureDevOpsCommandOutputDeviceData(line), cancellationToken).ConfigureAwait(false); +- } +- +- private TimeSpan ResolveThreshold(string testName) +- { +- bool hasStats = _historyService.TryGetDurationStats(testName, out DurationHistoryStats stats); +- return AzureDevOpsSlowTestThresholds.ComputeThreshold(_staticThreshold, stats, hasStats, Volatile.Read(ref _multiplier), _minimumSampleCount); ++ return OutputDevice.DisplayAsync(this, new AzureDevOpsCommandOutputDeviceData(line), cancellationToken); + } + + private double GetMultiplier() +@@ -287,28 +121,4 @@ private int GetMinimumSampleCount() + && minimum >= 1 + ? minimum + : AzureDevOpsCommandLineOptions.SlowTestHistoryDefaultMinSample; +- +- private void LogUnexpectedException(string callbackName, Exception ex) +- { +- if (_logger.IsEnabled(LogLevel.Warning)) +- { +- _logger.LogWarning($"Unexpected exception in {callbackName}: {ex}"); +- } +- } +- +- private sealed class InProgressTest +- { +- public InProgressTest(string testName, DateTimeOffset startTime, TimeSpan threshold) +- { +- TestName = testName; +- StartTime = startTime; +- NextEmitThreshold = threshold; +- } +- +- public string TestName { get; } +- +- public DateTimeOffset StartTime { get; } +- +- public TimeSpan NextEmitThreshold { get; set; } +- } + } +diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Microsoft.Testing.Extensions.AzureDevOpsReport.csproj b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Microsoft.Testing.Extensions.AzureDevOpsReport.csproj +index 0fac9243c2..a6ce74a8ef 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Microsoft.Testing.Extensions.AzureDevOpsReport.csproj ++++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Microsoft.Testing.Extensions.AzureDevOpsReport.csproj +@@ -14,6 +14,7 @@ + + + ++ + + + +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsExtensions.cs b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsExtensions.cs +index ea3683844a..8da36c9db2 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsExtensions.cs ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsExtensions.cs +@@ -11,6 +11,7 @@ namespace Microsoft.Testing.Extensions; + /// + /// Provides extension methods for adding GitHub Actions reporting support to the test application builder. + /// ++[Experimental("TPEXP", UrlFormat = "https://aka.ms/testingplatform/diagnostics#{0}")] + public static class GitHubActionsExtensions + { + /// +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsSlowTestReporter.cs b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsSlowTestReporter.cs +index c54947243f..6e7885191d 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsSlowTestReporter.cs ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsSlowTestReporter.cs +@@ -3,14 +3,10 @@ + + using Microsoft.Testing.Extensions.GitHubActionsReport.Resources; + using Microsoft.Testing.Platform.CommandLine; +-using Microsoft.Testing.Platform.Extensions; + using Microsoft.Testing.Platform.Extensions.Messages; +-using Microsoft.Testing.Platform.Extensions.OutputDevice; +-using Microsoft.Testing.Platform.Extensions.TestHost; + using Microsoft.Testing.Platform.Helpers; + using Microsoft.Testing.Platform.Logging; + using Microsoft.Testing.Platform.OutputDevice; +-using Microsoft.Testing.Platform.Services; + + namespace Microsoft.Testing.Extensions.GitHubActionsReport; + +@@ -18,26 +14,11 @@ namespace Microsoft.Testing.Extensions.GitHubActionsReport; + /// Surfaces tests that are still running past a per-test threshold as GitHub Actions ::notice + /// workflow commands, mirroring AzureDevOpsSlowTestReporter. + /// +-/// +-/// This is a self-contained emitter for the GitHub Actions host. Once the platform-level +-/// IProgressEnricher hook (issue #9139) ships, the surfacing/backoff logic should migrate onto it. +-/// +-internal sealed class GitHubActionsSlowTestReporter : IDataConsumer, ITestSessionLifetimeHandler, IOutputDeviceDataProducer ++internal sealed class GitHubActionsSlowTestReporter : SlowTestReporterBase + { +- private static readonly TimeSpan ScanInterval = TimeSpan.FromSeconds(1); +- +- private readonly IOutputDevice _outputDevice; +- private readonly ITask _task; +- private readonly IClock _clock; +- private readonly ILogger _logger; +- private readonly ConcurrentDictionary _inProgress = new(StringComparer.Ordinal); + private readonly bool _isEnabled; + private readonly TimeSpan _threshold; + +- private volatile bool _active; +- private CancellationTokenSource? _loopCancellationTokenSource; +- private Task? _loopTask; +- + public GitHubActionsSlowTestReporter( + ICommandLineOptions commandLineOptions, + IEnvironment environment, +@@ -45,175 +26,28 @@ public GitHubActionsSlowTestReporter( + ITask task, + IClock clock, + ILoggerFactory loggerFactory) ++ : base(outputDevice, task, clock, loggerFactory.CreateLogger()) + { +- _outputDevice = outputDevice; +- _task = task; +- _clock = clock; +- _logger = loggerFactory.CreateLogger(); + _isEnabled = GitHubActionsFeature.IsEnabled(commandLineOptions, environment, GitHubActionsCommandLineOptions.GitHubActionsSlowTestNotices); + _threshold = TimeSpan.FromSeconds(GetThresholdSeconds(commandLineOptions)); + } + +- public Type[] DataTypesConsumed { get; } = [typeof(TestNodeUpdateMessage)]; +- +- public string Uid => nameof(GitHubActionsSlowTestReporter); +- +- public string Version => ExtensionVersion.DefaultSemVer; +- +- public string DisplayName => GitHubActionsResources.DisplayName; +- +- public string Description => GitHubActionsResources.Description; +- +- public Task IsEnabledAsync() => Task.FromResult(_isEnabled); +- +- public Task OnTestSessionStartingAsync(ITestSessionContext testSessionContext) +- { +- try +- { +- testSessionContext.CancellationToken.ThrowIfCancellationRequested(); +- +- _active = false; +- _inProgress.Clear(); +- +- if (!_isEnabled) +- { +- return Task.CompletedTask; +- } +- +- _loopCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(testSessionContext.CancellationToken); +- _active = true; +- _loopTask = _task.RunLongRunning(() => ScanLoopAsync(_loopCancellationTokenSource.Token), nameof(GitHubActionsSlowTestReporter), _loopCancellationTokenSource.Token); +- } +- catch (OperationCanceledException) +- { +- throw; +- } +- catch (Exception ex) +- { +- LogUnexpectedException(nameof(OnTestSessionStartingAsync), ex); +- } +- +- return Task.CompletedTask; +- } +- +- public Task ConsumeAsync(IDataProducer dataProducer, IData value, CancellationToken cancellationToken) +- { +- try +- { +- cancellationToken.ThrowIfCancellationRequested(); +- +- if (!_active || value is not TestNodeUpdateMessage update) +- { +- return Task.CompletedTask; +- } +- +- string uid = update.TestNode.Uid; +- TestNodeStateProperty? state = update.TestNode.Properties.SingleOrDefault(); +- if (state is InProgressTestNodeStateProperty) +- { +- string testName = TestNodeIdentity.GetTestName(update.TestNode); +- _inProgress[uid] = new InProgressTest(testName, _clock.UtcNow, _threshold); +- } +- else if (state is not null) +- { +- _inProgress.TryRemove(uid, out _); +- } +- } +- catch (OperationCanceledException) +- { +- throw; +- } +- catch (Exception ex) +- { +- LogUnexpectedException(nameof(ConsumeAsync), ex); +- } +- +- return Task.CompletedTask; +- } +- +- public async Task OnTestSessionFinishingAsync(ITestSessionContext testSessionContext) +- { +- _active = false; +- +- CancellationTokenSource? loopCancellationTokenSource = _loopCancellationTokenSource; +- if (loopCancellationTokenSource is not null) +- { +-#pragma warning disable VSTHRD103 // CancelAsync is unavailable on all target frameworks. +- loopCancellationTokenSource.Cancel(); +-#pragma warning restore VSTHRD103 +- } +- +- if (_loopTask is not null) +- { +- try +- { +- await _loopTask.ConfigureAwait(false); +- } +- catch (OperationCanceledException) +- { +- // Expected during normal shutdown. +- } +- catch (Exception ex) +- { +- LogUnexpectedException(nameof(OnTestSessionFinishingAsync), ex); +- } +- } ++ public override string Uid => nameof(GitHubActionsSlowTestReporter); + +- loopCancellationTokenSource?.Dispose(); +- _loopCancellationTokenSource = null; +- _loopTask = null; +- _inProgress.Clear(); +- } ++ public override string DisplayName => GitHubActionsResources.DisplayName; + +- private async Task ScanLoopAsync(CancellationToken cancellationToken) +- { +- while (!cancellationToken.IsCancellationRequested) +- { +- try +- { +- await _task.Delay(ScanInterval, cancellationToken).ConfigureAwait(false); +- await ScanOnceAsync(_clock.UtcNow, cancellationToken).ConfigureAwait(false); +- } +- catch (OperationCanceledException) +- { +- return; +- } +- } +- } ++ public override string Description => GitHubActionsResources.Description; + +- // Internal for unit testing: performs a single surfacing pass at the given 'now'. +- internal async Task ScanOnceAsync(DateTimeOffset now, CancellationToken cancellationToken) +- { +- foreach (KeyValuePair entry in _inProgress) +- { +- InProgressTest test = entry.Value; +- TimeSpan elapsed = now - test.StartTime; +- if (elapsed < test.NextEmitThreshold) +- { +- continue; +- } ++ protected override bool IsEnabled => _isEnabled; + +- // Exponential backoff so a genuinely stuck test does not spam the log: T, 2T, 4T, ... +- long currentTicks = test.NextEmitThreshold.Ticks; +- test.NextEmitThreshold = currentTicks > TimeSpan.MaxValue.Ticks / 2 +- ? TimeSpan.MaxValue +- : TimeSpan.FromTicks(currentTicks * 2); ++ protected override string GetTestName(TestNode testNode) => TestNodeIdentity.GetTestName(testNode); + +- try +- { +- await EmitSlowTestAsync(test, elapsed, cancellationToken).ConfigureAwait(false); +- } +- catch (Exception ex) when (ex is not OperationCanceledException) +- { +- LogUnexpectedException(nameof(ScanOnceAsync), ex); +- } +- } +- } ++ protected override TimeSpan ResolveThreshold(string testName) => _threshold; + +- private async Task EmitSlowTestAsync(InProgressTest test, TimeSpan elapsed, CancellationToken cancellationToken) ++ protected override Task EmitSlowTestAsync(string testName, TimeSpan elapsed, CancellationToken cancellationToken) + { +- string line = BuildNoticeLine(test.TestName, elapsed); +- await _outputDevice.DisplayAsync(this, new FormattedTextOutputDeviceData(line), cancellationToken).ConfigureAwait(false); ++ string line = BuildNoticeLine(testName, elapsed); ++ return OutputDevice.DisplayAsync(this, new FormattedTextOutputDeviceData(line), cancellationToken); + } + + internal static /* for testing */ string BuildNoticeLine(string testName, TimeSpan elapsed) +@@ -239,28 +73,4 @@ private static int GetThresholdSeconds(ICommandLineOptions commandLineOptions) + && seconds >= 1 + ? seconds + : GitHubActionsCommandLineOptions.SlowTestThresholdDefaultSeconds; +- +- private void LogUnexpectedException(string callbackName, Exception ex) +- { +- if (_logger.IsEnabled(LogLevel.Warning)) +- { +- _logger.LogWarning($"Unexpected exception in {callbackName}: {ex}"); +- } +- } +- +- private sealed class InProgressTest +- { +- public InProgressTest(string testName, DateTimeOffset startTime, TimeSpan threshold) +- { +- TestName = testName; +- StartTime = startTime; +- NextEmitThreshold = threshold; +- } +- +- public string TestName { get; } +- +- public DateTimeOffset StartTime { get; } +- +- public TimeSpan NextEmitThreshold { get; set; } +- } + } +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Microsoft.Testing.Extensions.GitHubActionsReport.csproj b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Microsoft.Testing.Extensions.GitHubActionsReport.csproj +index 48b90c93c7..190690a218 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Microsoft.Testing.Extensions.GitHubActionsReport.csproj ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Microsoft.Testing.Extensions.GitHubActionsReport.csproj +@@ -2,6 +2,9 @@ + + + netstandard2.0;$(SupportedNetFrameworks) ++ $(MicrosoftTestingExtensionsGitHubActionsReportVersionPrefix) ++ $(MicrosoftTestingExtensionsGitHubActionsReportPreReleaseVersionLabel) ++ true + true + $(RepoRoot)src\Platform\SharedExtensionHelpers\BuildInfo.cs.template + +@@ -12,7 +15,7 @@ + + + +- ++ + + + +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/PublicAPI/PublicAPI.Unshipped.txt b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/PublicAPI/PublicAPI.Unshipped.txt +index 0a19cd14d6..cfdb07bbec 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/PublicAPI/PublicAPI.Unshipped.txt ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/PublicAPI/PublicAPI.Unshipped.txt +@@ -1,5 +1,5 @@ + #nullable enable +-Microsoft.Testing.Extensions.GitHubActionsExtensions ++[TPEXP]Microsoft.Testing.Extensions.GitHubActionsExtensions + Microsoft.Testing.Extensions.GitHubActionsReport.TestingPlatformBuilderHook +-static Microsoft.Testing.Extensions.GitHubActionsExtensions.AddGitHubActionsProvider(this Microsoft.Testing.Platform.Builder.ITestApplicationBuilder! builder) -> void ++[TPEXP]static Microsoft.Testing.Extensions.GitHubActionsExtensions.AddGitHubActionsProvider(this Microsoft.Testing.Platform.Builder.ITestApplicationBuilder! builder) -> void + static Microsoft.Testing.Extensions.GitHubActionsReport.TestingPlatformBuilderHook.AddExtensions(Microsoft.Testing.Platform.Builder.ITestApplicationBuilder! testApplicationBuilder, string![]! _) -> void +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/TestNodeIdentity.cs b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/TestNodeIdentity.cs +new file mode 100644 +index 0000000000..5e0c99a490 +--- /dev/null ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/TestNodeIdentity.cs +@@ -0,0 +1,57 @@ ++// Copyright (c) Microsoft Corporation. All rights reserved. ++// Licensed under the MIT license. See LICENSE file in the project root for full license information. ++ ++using Microsoft.Testing.Platform; ++using Microsoft.Testing.Platform.Extensions.Messages; ++ ++namespace Microsoft.Testing.Extensions.GitHubActionsReport; ++ ++internal static class TestNodeIdentity ++{ ++ private const string FullyQualifiedNamePropertyKey = "vstest.TestCase.FullyQualifiedName"; ++ ++ /// ++ /// Resolves the stable, fully-qualified test name for a . Falls back to the ++ /// display name when the fully-qualified name property is unavailable. ++ /// ++ /// ++ /// The identity is resolved in the same priority order used by the TRX and VSTest-bridge converters: ++ /// ++ /// — the canonical, ECMA-335 compliant identity added by ++ /// frameworks running natively on the platform. ++ /// The non-standard vstest.TestCase.FullyQualifiedName property (only present for tests flowing ++ /// through the VSTest bridge in server mode). ++ /// The display name, as a last resort. ++ /// ++ /// In practice a given carries at most one of the first two properties, so the walk order does ++ /// not matter; we still prefer when both are somehow present. ++ /// ++ public static string GetTestName(TestNode testNode) ++ { ++ // Walk the PropertyBag once with the zero-allocation struct enumerator, avoiding the LINQ ++ // iterator/boxed-enumerator allocations that OfType().FirstOrDefault(...) would incur. ++ // Short-circuit as soon as the preferred TestMethodIdentifierProperty is found; otherwise ++ // remember the first VSTest fully-qualified name as a fallback. ++ string? fullyQualifiedName = null; ++ using PropertyBag.PropertyBagEnumerator enumerator = testNode.Properties.GetStructEnumerator(); ++ while (enumerator.MoveNext()) ++ { ++ switch (enumerator.Current) ++ { ++ case TestMethodIdentifierProperty testMethodIdentifier: ++ return FormatFullyQualifiedName(testMethodIdentifier); ++ ++ case SerializableKeyValuePairStringProperty { Key: FullyQualifiedNamePropertyKey } kvp when fullyQualifiedName is null: ++ fullyQualifiedName = kvp.Value; ++ break; ++ } ++ } ++ ++ return fullyQualifiedName ?? testNode.DisplayName; ++ } ++ ++ private static string FormatFullyQualifiedName(TestMethodIdentifierProperty testMethodIdentifier) ++ => RoslynString.IsNullOrEmpty(testMethodIdentifier.Namespace) ++ ? $"{testMethodIdentifier.TypeName}.{testMethodIdentifier.MethodName}" ++ : $"{testMethodIdentifier.Namespace}.{testMethodIdentifier.TypeName}.{testMethodIdentifier.MethodName}"; ++} +diff --git a/src/Platform/SharedExtensionHelpers/SlowTestReporterBase.cs b/src/Platform/SharedExtensionHelpers/SlowTestReporterBase.cs +new file mode 100644 +index 0000000000..5eb69ee799 +--- /dev/null ++++ b/src/Platform/SharedExtensionHelpers/SlowTestReporterBase.cs +@@ -0,0 +1,266 @@ ++// Copyright (c) Microsoft Corporation. All rights reserved. ++// Licensed under the MIT license. See LICENSE file in the project root for full license information. ++ ++using Microsoft.Testing.Platform.Extensions; ++using Microsoft.Testing.Platform.Extensions.Messages; ++using Microsoft.Testing.Platform.Extensions.OutputDevice; ++using Microsoft.Testing.Platform.Extensions.TestHost; ++using Microsoft.Testing.Platform.Helpers; ++using Microsoft.Testing.Platform.Logging; ++using Microsoft.Testing.Platform.OutputDevice; ++using Microsoft.Testing.Platform.Services; ++ ++namespace Microsoft.Testing.Extensions; ++ ++/// ++/// Shared plumbing for the pipeline-specific slow-test reporters (Azure DevOps and GitHub Actions). ++/// Tracks in-progress tests, runs the background scan loop, and applies exponential backoff so a ++/// genuinely stuck test does not spam the log. Host-specific concerns — how to resolve the emission ++/// threshold and how to render the surfaced line — are supplied by derived types. ++/// ++/// ++/// Once the platform-level IProgressEnricher hook (issue #9139) ships, the surfacing/backoff logic ++/// here should migrate onto it and each host should only supply the threshold and decoration. ++/// ++internal abstract class SlowTestReporterBase : IDataConsumer, ITestSessionLifetimeHandler, IOutputDeviceDataProducer ++{ ++ private static readonly TimeSpan ScanInterval = TimeSpan.FromSeconds(1); ++ ++ private readonly ITask _task; ++ private readonly IClock _clock; ++ private readonly ConcurrentDictionary _inProgress = new(StringComparer.Ordinal); ++ ++ private volatile bool _active; ++ private CancellationTokenSource? _loopCancellationTokenSource; ++ private Task? _loopTask; ++ ++ protected SlowTestReporterBase(IOutputDevice outputDevice, ITask task, IClock clock, ILogger logger) ++ { ++ OutputDevice = outputDevice; ++ _task = task; ++ _clock = clock; ++ Logger = logger; ++ } ++ ++ public Type[] DataTypesConsumed { get; } = [typeof(TestNodeUpdateMessage)]; ++ ++ public abstract string Uid { get; } ++ ++ public string Version => ExtensionVersion.DefaultSemVer; ++ ++ public abstract string DisplayName { get; } ++ ++ public abstract string Description { get; } ++ ++ /// ++ /// Gets a value indicating whether the reporter is enabled, based on the host-specific options. ++ /// ++ protected abstract bool IsEnabled { get; } ++ ++ protected IOutputDevice OutputDevice { get; } ++ ++ protected ILogger Logger { get; } ++ ++ public Task IsEnabledAsync() => Task.FromResult(IsEnabled); ++ ++ public Task OnTestSessionStartingAsync(ITestSessionContext testSessionContext) ++ { ++ try ++ { ++ testSessionContext.CancellationToken.ThrowIfCancellationRequested(); ++ ++ _active = false; ++ _inProgress.Clear(); ++ ++ if (!IsEnabled) ++ { ++ return Task.CompletedTask; ++ } ++ ++ // Host-specific activation gate (e.g. Azure DevOps requires TF_BUILD and warms up history state). ++ if (!OnActivating()) ++ { ++ return Task.CompletedTask; ++ } ++ ++ _loopCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(testSessionContext.CancellationToken); ++ _active = true; ++ _loopTask = _task.RunLongRunning(() => ScanLoopAsync(_loopCancellationTokenSource.Token), Uid, _loopCancellationTokenSource.Token); ++ } ++ catch (OperationCanceledException) ++ { ++ throw; ++ } ++ catch (Exception ex) ++ { ++ LogUnexpectedException(nameof(OnTestSessionStartingAsync), ex); ++ } ++ ++ return Task.CompletedTask; ++ } ++ ++ public Task ConsumeAsync(IDataProducer dataProducer, IData value, CancellationToken cancellationToken) ++ { ++ try ++ { ++ cancellationToken.ThrowIfCancellationRequested(); ++ ++ if (!_active || value is not TestNodeUpdateMessage update) ++ { ++ return Task.CompletedTask; ++ } ++ ++ string uid = update.TestNode.Uid; ++ TestNodeStateProperty? state = update.TestNode.Properties.SingleOrDefault(); ++ if (state is InProgressTestNodeStateProperty) ++ { ++ string testName = GetTestName(update.TestNode); ++ TimeSpan threshold = ResolveThreshold(testName); ++ _inProgress[uid] = new InProgressTest(testName, _clock.UtcNow, threshold); ++ } ++ else if (state is not null) ++ { ++ // Any non-in-progress state (passed/failed/skipped/error/timeout/cancelled) is terminal for surfacing. ++ _inProgress.TryRemove(uid, out _); ++ } ++ } ++ catch (OperationCanceledException) ++ { ++ throw; ++ } ++ catch (Exception ex) ++ { ++ LogUnexpectedException(nameof(ConsumeAsync), ex); ++ } ++ ++ return Task.CompletedTask; ++ } ++ ++ public async Task OnTestSessionFinishingAsync(ITestSessionContext testSessionContext) ++ { ++ _active = false; ++ ++ CancellationTokenSource? loopCancellationTokenSource = _loopCancellationTokenSource; ++ if (loopCancellationTokenSource is not null) ++ { ++#pragma warning disable VSTHRD103 // CancelAsync is unavailable on all target frameworks. ++ loopCancellationTokenSource.Cancel(); ++#pragma warning restore VSTHRD103 ++ } ++ ++ if (_loopTask is not null) ++ { ++ try ++ { ++ await _loopTask.ConfigureAwait(false); ++ } ++ catch (OperationCanceledException) ++ { ++ // Expected during normal shutdown: cancelling _loopCancellationTokenSource above unblocks the ++ // scan loop, which surfaces as a cancellation here. Nothing to do — swallow and finish teardown. ++ } ++ catch (Exception ex) ++ { ++ LogUnexpectedException(nameof(OnTestSessionFinishingAsync), ex); ++ } ++ } ++ ++ loopCancellationTokenSource?.Dispose(); ++ _loopCancellationTokenSource = null; ++ _loopTask = null; ++ _inProgress.Clear(); ++ } ++ ++ /// ++ /// Resolves the stable, fully-qualified test name for a . ++ /// ++ protected abstract string GetTestName(TestNode testNode); ++ ++ /// ++ /// Resolves the elapsed time after which the given test should first surface a slow-test notice. ++ /// ++ protected abstract TimeSpan ResolveThreshold(string testName); ++ ++ /// ++ /// Renders and emits the host-specific slow-test line for a test that has passed its threshold. ++ /// ++ protected abstract Task EmitSlowTestAsync(string testName, TimeSpan elapsed, CancellationToken cancellationToken); ++ ++ /// ++ /// Host-specific activation gate invoked once the option-based check has passed. ++ /// Returns to keep the reporter dormant (e.g. when not running on the host CI). ++ /// ++ protected virtual bool OnActivating() => true; ++ ++ private async Task ScanLoopAsync(CancellationToken cancellationToken) ++ { ++ while (!cancellationToken.IsCancellationRequested) ++ { ++ try ++ { ++ await _task.Delay(ScanInterval, cancellationToken).ConfigureAwait(false); ++ await ScanOnceAsync(_clock.UtcNow, cancellationToken).ConfigureAwait(false); ++ } ++ catch (OperationCanceledException) ++ { ++ return; ++ } ++ } ++ } ++ ++ // Internal for unit testing: performs a single surfacing pass at the given 'now' so tests can drive ++ // the emission/backoff logic deterministically without relying on the timer-driven loop. ++ internal async Task ScanOnceAsync(DateTimeOffset now, CancellationToken cancellationToken) ++ { ++ foreach (KeyValuePair entry in _inProgress) ++ { ++ InProgressTest test = entry.Value; ++ TimeSpan elapsed = now - test.StartTime; ++ if (elapsed < test.NextEmitThreshold) ++ { ++ continue; ++ } ++ ++ // Exponential backoff so a genuinely stuck test does not spam the log: T, 2T, 4T, ... ++ // Clamp at TimeSpan.MaxValue so a very long-running test cannot overflow Ticks * 2 into a ++ // negative value (which would make the backoff fire on every scan). ++ long currentTicks = test.NextEmitThreshold.Ticks; ++ test.NextEmitThreshold = currentTicks > TimeSpan.MaxValue.Ticks / 2 ++ ? TimeSpan.MaxValue ++ : TimeSpan.FromTicks(currentTicks * 2); ++ ++ try ++ { ++ await EmitSlowTestAsync(test.TestName, elapsed, cancellationToken).ConfigureAwait(false); ++ } ++ catch (Exception ex) when (ex is not OperationCanceledException) ++ { ++ LogUnexpectedException(nameof(ScanOnceAsync), ex); ++ } ++ } ++ } ++ ++ private void LogUnexpectedException(string callbackName, Exception ex) ++ { ++ if (Logger.IsEnabled(LogLevel.Warning)) ++ { ++ Logger.LogWarning($"Unexpected exception in {callbackName}: {ex}"); ++ } ++ } ++ ++ private sealed class InProgressTest ++ { ++ public InProgressTest(string testName, DateTimeOffset startTime, TimeSpan threshold) ++ { ++ TestName = testName; ++ StartTime = startTime; ++ NextEmitThreshold = threshold; ++ } ++ ++ public string TestName { get; } ++ ++ public DateTimeOffset StartTime { get; } ++ ++ public TimeSpan NextEmitThreshold { get; set; } ++ } ++} +diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/AzureDevOpsTestNodeIdentityTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/AzureDevOpsTestNodeIdentityTests.cs +index 3fd223eec6..91c4758d45 100644 +--- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/AzureDevOpsTestNodeIdentityTests.cs ++++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/AzureDevOpsTestNodeIdentityTests.cs +@@ -1,7 +1,6 @@ + // Copyright (c) Microsoft Corporation. All rights reserved. + // Licensed under the MIT license. See LICENSE file in the project root for full license information. + +-using Microsoft.Testing.Extensions.AzureDevOpsReport; + using Microsoft.Testing.Platform.Extensions.Messages; + + namespace Microsoft.Testing.Extensions.UnitTests; + +From f7c6dfe644853c7661b543ee68375346e822294a Mon Sep 17 00:00:00 2001 +From: Azat Mukhametshin +Date: Wed, 1 Jul 2026 20:50:16 +0200 +Subject: [PATCH 16/32] Fix CI: pack GitHubActionsReport and reference its + 1.0.0-alpha version in acceptance tests + +The GitHubActionsReport project was present in TestFx.slnx and wired into the +MSTest SDK / acceptance tests, but was missing from the solution filters the +CI builds and packs from (Microsoft.Testing.Platform.slnf, NonWindowsTests.slnf), +so the package was never produced. This caused NU1101 (package not found) in the +HelpInfoAllExtensionsTests and MSTestSdk acceptance assets. + +- Add Microsoft.Testing.Extensions.GitHubActionsReport to both solution filters + (alongside AzureDevOpsReport) so it gets built and packed in CI. +- Because the package now ships as 1.0.0-alpha (not the platform common version), + reference it via a dedicated $MicrosoftTestingExtensionsGitHubActionsReportVersion$ + placeholder read from the packed nupkg (mirroring JUnitReport/CtrfReport), + instead of $MicrosoftTestingPlatformVersion$. + +Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> +--- + Microsoft.Testing.Platform.slnf | 1 + + NonWindowsTests.slnf | 1 + + .../HelpInfoAllExtensionsTests.cs | 3 ++- + .../Helpers/AcceptanceTestBase.cs | 3 +++ + 4 files changed, 7 insertions(+), 1 deletion(-) + +diff --git a/Microsoft.Testing.Platform.slnf b/Microsoft.Testing.Platform.slnf +index 0f0d160473..ce68c6a4b8 100644 +--- a/Microsoft.Testing.Platform.slnf ++++ b/Microsoft.Testing.Platform.slnf +@@ -10,6 +10,7 @@ + "src\\Platform\\Microsoft.Testing.Extensions.AzureFoundry\\Microsoft.Testing.Extensions.AzureFoundry.csproj", + "src\\Platform\\Microsoft.Testing.Extensions.CrashDump\\Microsoft.Testing.Extensions.CrashDump.csproj", + "src\\Platform\\Microsoft.Testing.Extensions.CtrfReport\\Microsoft.Testing.Extensions.CtrfReport.csproj", ++ "src\\Platform\\Microsoft.Testing.Extensions.GitHubActionsReport\\Microsoft.Testing.Extensions.GitHubActionsReport.csproj", + "src\\Platform\\Microsoft.Testing.Extensions.HangDump\\Microsoft.Testing.Extensions.HangDump.csproj", + "src\\Platform\\Microsoft.Testing.Extensions.HotReload\\Microsoft.Testing.Extensions.HotReload.csproj", + "src\\Platform\\Microsoft.Testing.Extensions.HtmlReport\\Microsoft.Testing.Extensions.HtmlReport.csproj", +diff --git a/NonWindowsTests.slnf b/NonWindowsTests.slnf +index 951085ae90..abaedca706 100644 +--- a/NonWindowsTests.slnf ++++ b/NonWindowsTests.slnf +@@ -16,6 +16,7 @@ + "src\\Platform\\Microsoft.Testing.Extensions.AzureFoundry\\Microsoft.Testing.Extensions.AzureFoundry.csproj", + "src\\Platform\\Microsoft.Testing.Extensions.CrashDump\\Microsoft.Testing.Extensions.CrashDump.csproj", + "src\\Platform\\Microsoft.Testing.Extensions.CtrfReport\\Microsoft.Testing.Extensions.CtrfReport.csproj", ++ "src\\Platform\\Microsoft.Testing.Extensions.GitHubActionsReport\\Microsoft.Testing.Extensions.GitHubActionsReport.csproj", + "src\\Platform\\Microsoft.Testing.Extensions.HangDump\\Microsoft.Testing.Extensions.HangDump.csproj", + "src\\Platform\\Microsoft.Testing.Extensions.HotReload\\Microsoft.Testing.Extensions.HotReload.csproj", + "src\\Platform\\Microsoft.Testing.Extensions.HtmlReport\\Microsoft.Testing.Extensions.HtmlReport.csproj", +diff --git a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/HelpInfoAllExtensionsTests.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/HelpInfoAllExtensionsTests.cs +index eb4d391928..5683f07ee8 100644 +--- a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/HelpInfoAllExtensionsTests.cs ++++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/HelpInfoAllExtensionsTests.cs +@@ -770,7 +770,7 @@ public sealed class TestAssetFixture() : TestAssetFixtureBase() + + + +- ++ + + + +@@ -837,6 +837,7 @@ public override (string ID, string Name, string Code) GetAssetsToGenerate() => ( + .PatchCodeWithReplace("$MicrosoftTestingPlatformVersion$", MicrosoftTestingPlatformVersion) + .PatchCodeWithReplace("$MicrosoftTestingExtensionsCtrfReportVersion$", MicrosoftTestingExtensionsCtrfReportVersion) + .PatchCodeWithReplace("$MicrosoftTestingExtensionsJUnitReportVersion$", MicrosoftTestingExtensionsJUnitReportVersion) ++ .PatchCodeWithReplace("$MicrosoftTestingExtensionsGitHubActionsReportVersion$", MicrosoftTestingExtensionsGitHubActionsReportVersion) + .PatchCodeWithReplace("$MicrosoftTestingExtensionsPackagedAppVersion$", MicrosoftTestingExtensionsPackagedAppVersion) + .PatchCodeWithReplace("$MicrosoftTestingExtensionsVideoRecorderVersion$", MicrosoftTestingExtensionsVideoRecorderVersion) + .PatchCodeWithReplace("$MicrosoftTestingExtensionsAzureFoundryVersion$", MicrosoftTestingExtensionsAzureFoundryVersion)); +diff --git a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/Helpers/AcceptanceTestBase.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/Helpers/AcceptanceTestBase.cs +index 4e94eef8ae..c690025824 100644 +--- a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/Helpers/AcceptanceTestBase.cs ++++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/Helpers/AcceptanceTestBase.cs +@@ -20,6 +20,7 @@ static AcceptanceTestBase() + MicrosoftTestingExtensionsLoggingVersion = ExtractVersionFromPackage(Constants.ArtifactsPackagesShipping, "Microsoft.Testing.Extensions.Logging."); + MicrosoftTestingExtensionsCtrfReportVersion = ExtractVersionFromPackage(Constants.ArtifactsPackagesShipping, "Microsoft.Testing.Extensions.CtrfReport."); + MicrosoftTestingExtensionsJUnitReportVersion = ExtractVersionFromPackage(Constants.ArtifactsPackagesShipping, "Microsoft.Testing.Extensions.JUnitReport."); ++ MicrosoftTestingExtensionsGitHubActionsReportVersion = ExtractVersionFromPackage(Constants.ArtifactsPackagesShipping, "Microsoft.Testing.Extensions.GitHubActionsReport."); + MicrosoftTestingExtensionsPackagedAppVersion = ExtractVersionFromPackage(Constants.ArtifactsPackagesShipping, "Microsoft.Testing.Extensions.PackagedApp."); + MicrosoftTestingExtensionsVideoRecorderVersion = ExtractVersionFromPackage(Constants.ArtifactsPackagesShipping, "Microsoft.Testing.Extensions.VideoRecorder."); + MicrosoftTestingExtensionsAzureFoundryVersion = ExtractVersionFromPackage(Constants.ArtifactsPackagesNonShipping, "Microsoft.Testing.Extensions.AzureFoundry."); +@@ -48,6 +49,8 @@ static AcceptanceTestBase() + + public static string MicrosoftTestingExtensionsJUnitReportVersion { get; private set; } + ++ public static string MicrosoftTestingExtensionsGitHubActionsReportVersion { get; private set; } ++ + public static string MicrosoftTestingExtensionsPackagedAppVersion { get; private set; } + + public static string MicrosoftTestingExtensionsVideoRecorderVersion { get; private set; } + +From d8901f748ea6f18a28888f139fe365059c574fe1 Mon Sep 17 00:00:00 2001 +From: Azat Mukhametshin +Date: Wed, 1 Jul 2026 23:44:32 +0200 +Subject: [PATCH 17/32] Harden GitHubActionsAnnotationReporter.ConsumeAsync + (Amaury review #2) + +- Wrap per-node work in try/catch + LogWarning, mirroring the sibling + GitHubActionsSlowTestReporter/GitHubActionsSummaryReporter so a failure while + building/emitting an annotation (e.g. a malformed stack-trace path making + IFileSystem.ExistFile throw) degrades to "no annotation for this test" instead + of propagating into the platform's data-consumer dispatch. +- Use FirstOrDefault instead of SingleOrDefault for the test-node state so a + malformed node carrying multiple state properties does not throw. +- Drop the dead testDisplayName parameter and compute GetTestName once. + +Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> +--- + .../GitHubActionsAnnotationReporter.cs | 68 +++++++++++++------ + 1 file changed, 46 insertions(+), 22 deletions(-) + +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsAnnotationReporter.cs b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsAnnotationReporter.cs +index 46d7fb8cc7..3ab013ae6a 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsAnnotationReporter.cs ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsAnnotationReporter.cs +@@ -75,36 +75,52 @@ public GitHubActionsAnnotationReporter( + + public async Task ConsumeAsync(IDataProducer dataProducer, IData value, CancellationToken cancellationToken) + { +- cancellationToken.ThrowIfCancellationRequested(); +- +- if (value is not TestNodeUpdateMessage nodeUpdateMessage) ++ try + { +- return; +- } ++ cancellationToken.ThrowIfCancellationRequested(); + +- TestNodeStateProperty? nodeState = nodeUpdateMessage.TestNode.Properties.SingleOrDefault(); +- string testDisplayName = nodeUpdateMessage.TestNode.DisplayName; ++ if (value is not TestNodeUpdateMessage nodeUpdateMessage) ++ { ++ return; ++ } + +- switch (nodeState) +- { +- case FailedTestNodeStateProperty failed: +- await WriteAnnotationAsync(testDisplayName, GetTestName(nodeUpdateMessage.TestNode), failed.Explanation, failed.Exception, cancellationToken).ConfigureAwait(false); +- break; +- case ErrorTestNodeStateProperty error: +- await WriteAnnotationAsync(testDisplayName, GetTestName(nodeUpdateMessage.TestNode), error.Explanation, error.Exception, cancellationToken).ConfigureAwait(false); +- break; +- case TimeoutTestNodeStateProperty timeout: +- await WriteAnnotationAsync(testDisplayName, GetTestName(nodeUpdateMessage.TestNode), timeout.Explanation, timeout.Exception, cancellationToken).ConfigureAwait(false); +- break; ++ // FirstOrDefault (not SingleOrDefault): a malformed node that somehow carries more than one state ++ // property must degrade to "no annotation for this test" rather than throwing into the platform's ++ // data-consumer dispatch. ++ TestNodeStateProperty? nodeState = nodeUpdateMessage.TestNode.Properties.FirstOrDefault(); ++ ++ (string? Explanation, Exception? Exception)? failure = nodeState switch ++ { ++ FailedTestNodeStateProperty failed => (failed.Explanation, failed.Exception), ++ ErrorTestNodeStateProperty error => (error.Explanation, error.Exception), ++ TimeoutTestNodeStateProperty timeout => (timeout.Explanation, timeout.Exception), + #pragma warning disable CS0618, MTP0001 // Type or member is obsolete +- case CancelledTestNodeStateProperty cancelled: ++ CancelledTestNodeStateProperty cancelled => (cancelled.Explanation, cancelled.Exception), + #pragma warning restore CS0618, MTP0001 // Type or member is obsolete +- await WriteAnnotationAsync(testDisplayName, GetTestName(nodeUpdateMessage.TestNode), cancelled.Explanation, cancelled.Exception, cancellationToken).ConfigureAwait(false); +- break; ++ _ => null, ++ }; ++ ++ if (failure is null) ++ { ++ return; ++ } ++ ++ await WriteAnnotationAsync(GetTestName(nodeUpdateMessage.TestNode), failure.Value.Explanation, failure.Value.Exception, cancellationToken).ConfigureAwait(false); ++ } ++ catch (OperationCanceledException) ++ { ++ throw; ++ } ++ catch (Exception ex) ++ { ++ // Mirror the sibling reporters: a failure while building/emitting an annotation (e.g. a malformed ++ // stack-trace path making IFileSystem.ExistFile throw) degrades to "no annotation" instead of ++ // propagating into the platform's consumer dispatch. ++ LogUnexpectedException(nameof(ConsumeAsync), ex); + } + } + +- private async Task WriteAnnotationAsync(string testDisplayName, string testName, string? explanation, Exception? exception, CancellationToken cancellationToken) ++ private async Task WriteAnnotationAsync(string testName, string? explanation, Exception? exception, CancellationToken cancellationToken) + { + if (_logger.IsEnabled(LogLevel.Trace)) + { +@@ -249,4 +265,12 @@ private static (string Code, string File, int LineNumber)? GetStackFrameLocation + + private static string GetTestName(TestNode testNode) + => TestNodeIdentity.GetTestName(testNode); ++ ++ private void LogUnexpectedException(string callbackName, Exception ex) ++ { ++ if (_logger.IsEnabled(LogLevel.Warning)) ++ { ++ _logger.LogWarning($"Unexpected exception in {callbackName}: {ex}"); ++ } ++ } + } + +From ee37b95a8cc751f2517dd9cda61a782a1b82fc3d Mon Sep 17 00:00:00 2001 +From: Azat Mukhametshin +Date: Wed, 1 Jul 2026 23:50:02 +0200 +Subject: [PATCH 18/32] Mirror AzDo group ordering guard in + GitHubActionsReporter (Amaury review #7) + +GitHubActionsReporter previously implemented only ITestSessionLifetimeHandler and +emitted ::endgroup:: unconditionally, relying purely on registration order. Mirror +the deliberate engineering of AzureDevOpsLogGroupReporter: +- Also implement IDataConsumer with a no-op ConsumeAsync so OnTestSessionFinishingAsync + runs in the consumer phase (after producer-only handlers), and register it last as a + data consumer so the closing ::endgroup:: wraps all other reporters' output. +- Add a _groupOpened flag so ::endgroup:: is only emitted when a matching ::group:: was + opened, and wrap both lifecycle callbacks in try/catch + LogWarning. + +Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> +--- + .../GitHubActionsExtensions.cs | 20 +++-- + .../GitHubActionsReporter.cs | 79 +++++++++++++++++-- + 2 files changed, 87 insertions(+), 12 deletions(-) + +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsExtensions.cs b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsExtensions.cs +index 8da36c9db2..f339915f01 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsExtensions.cs ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsExtensions.cs +@@ -38,6 +38,14 @@ public static void AddGitHubActionsProvider(this ITestApplicationBuilder builder + serviceProvider.GetClock(), + serviceProvider.GetLoggerFactory())); + ++ var compositeReporter = new CompositeExtensionFactory(serviceProvider => ++ new GitHubActionsReporter( ++ serviceProvider.GetCommandLineOptions(), ++ serviceProvider.GetEnvironment(), ++ serviceProvider.GetOutputDevice(), ++ serviceProvider.GetTestApplicationModuleInfo(), ++ serviceProvider.GetLoggerFactory())); ++ + builder.TestHost.AddDataConsumer(serviceProvider => + new GitHubActionsAnnotationReporter( + serviceProvider.GetCommandLineOptions(), +@@ -50,13 +58,11 @@ public static void AddGitHubActionsProvider(this ITestApplicationBuilder builder + builder.TestHost.AddTestSessionLifetimeHandler(compositeSummaryReporter); + builder.TestHost.AddDataConsumer(compositeSlowTestReporter); + builder.TestHost.AddTestSessionLifetimeHandler(compositeSlowTestReporter); +- builder.TestHost.AddTestSessionLifetimeHandler(serviceProvider => +- new GitHubActionsReporter( +- serviceProvider.GetCommandLineOptions(), +- serviceProvider.GetEnvironment(), +- serviceProvider.GetOutputDevice(), +- serviceProvider.GetTestApplicationModuleInfo(), +- serviceProvider.GetLoggerFactory())); ++ ++ // Register the group reporter last, as both a data consumer (no-op) and a session-lifetime handler, so its ++ // closing '::endgroup::' is ordered into the consumer phase after every other reporter's final output. ++ builder.TestHost.AddDataConsumer(compositeReporter); ++ builder.TestHost.AddTestSessionLifetimeHandler(compositeReporter); + builder.CommandLine.AddProvider(() => new GitHubActionsCommandLineProvider()); + } + } +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsReporter.cs b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsReporter.cs +index f3d1556848..624323b4b4 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsReporter.cs ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsReporter.cs +@@ -1,8 +1,10 @@ +-// Copyright (c) Microsoft Corporation. All rights reserved. ++// Copyright (c) Microsoft Corporation. All rights reserved. + // Licensed under the MIT license. See LICENSE file in the project root for full license information. + + using Microsoft.Testing.Extensions.GitHubActionsReport.Resources; + using Microsoft.Testing.Platform.CommandLine; ++using Microsoft.Testing.Platform.Extensions; ++using Microsoft.Testing.Platform.Extensions.Messages; + using Microsoft.Testing.Platform.Extensions.OutputDevice; + using Microsoft.Testing.Platform.Extensions.TestHost; + using Microsoft.Testing.Platform.Helpers; +@@ -12,7 +14,21 @@ + + namespace Microsoft.Testing.Extensions.GitHubActionsReport; + ++/// ++/// Wraps each test assembly's output in a GitHub Actions log group (::group:: / ::endgroup::) ++/// so the runner UI collapses the assembly's test output by default. ++/// ++/// ++/// Like the Azure DevOps sibling (AzureDevOpsLogGroupReporter), this handler also implements ++/// (with a no-op ) ++/// purely so that, at session end, its runs in the ++/// consumer phase — i.e. after the producer-only handlers. Combined with registering it last, this ensures the ++/// closing ::endgroup:: is emitted after the other reporters' final output, so the group truly wraps the ++/// whole assembly's output. The flag guarantees ::endgroup:: is only emitted ++/// when a matching ::group:: was actually opened. ++/// + internal sealed class GitHubActionsReporter : ++ IDataConsumer, + ITestSessionLifetimeHandler, + IOutputDeviceDataProducer + { +@@ -23,6 +39,8 @@ internal sealed class GitHubActionsReporter : + private readonly ILogger _logger; + private readonly string _targetFrameworkMoniker; + ++ private bool _groupOpened; ++ + public GitHubActionsReporter( + ICommandLineOptions commandLine, + IEnvironment environment, +@@ -50,6 +68,9 @@ public GitHubActionsReporter( + /// + public string Description { get; } = GitHubActionsResources.Description; + ++ /// ++ public Type[] DataTypesConsumed { get; } = [typeof(TestNodeUpdateMessage)]; ++ + /// + public Task IsEnabledAsync() + { +@@ -63,15 +84,63 @@ public Task IsEnabledAsync() + return Task.FromResult(isEnabled); + } + ++ // No-op: this consumer subscribes to data only to be ordered in the consumer phase at session end ++ // (see the type-level remarks). It does not act on individual messages. ++ public Task ConsumeAsync(IDataProducer dataProducer, IData value, CancellationToken cancellationToken) ++ => Task.CompletedTask; ++ + /// + public async Task OnTestSessionStartingAsync(ITestSessionContext testSessionContext) + { +- string name = _testApplicationModuleInfo.TryGetAssemblyName() ?? _testApplicationModuleInfo.GetDisplayName(); +- string title = string.Format(CultureInfo.InvariantCulture, GitHubActionsResources.GroupTitle, name, _targetFrameworkMoniker); +- await _outputDisplay.DisplayAsync(this, new FormattedTextOutputDeviceData($"::group::{GitHubActionsEscaper.EscapeData(title)}"), testSessionContext.CancellationToken).ConfigureAwait(false); ++ try ++ { ++ testSessionContext.CancellationToken.ThrowIfCancellationRequested(); ++ ++ string name = _testApplicationModuleInfo.TryGetAssemblyName() ?? _testApplicationModuleInfo.GetDisplayName(); ++ string title = string.Format(CultureInfo.InvariantCulture, GitHubActionsResources.GroupTitle, name, _targetFrameworkMoniker); ++ await _outputDisplay.DisplayAsync(this, new FormattedTextOutputDeviceData($"::group::{GitHubActionsEscaper.EscapeData(title)}"), testSessionContext.CancellationToken).ConfigureAwait(false); ++ _groupOpened = true; ++ } ++ catch (OperationCanceledException) ++ { ++ throw; ++ } ++ catch (Exception ex) ++ { ++ LogUnexpectedException(nameof(OnTestSessionStartingAsync), ex); ++ } + } + + /// + public async Task OnTestSessionFinishingAsync(ITestSessionContext testSessionContext) +- => await _outputDisplay.DisplayAsync(this, new FormattedTextOutputDeviceData("::endgroup::"), testSessionContext.CancellationToken).ConfigureAwait(false); ++ { ++ try ++ { ++ testSessionContext.CancellationToken.ThrowIfCancellationRequested(); ++ ++ if (!_groupOpened) ++ { ++ return; ++ } ++ ++ await _outputDisplay.DisplayAsync(this, new FormattedTextOutputDeviceData("::endgroup::"), testSessionContext.CancellationToken).ConfigureAwait(false); ++ _groupOpened = false; ++ } ++ catch (OperationCanceledException) ++ { ++ throw; ++ } ++ catch (Exception ex) ++ { ++ LogUnexpectedException(nameof(OnTestSessionFinishingAsync), ex); ++ } ++ } ++ ++ private void LogUnexpectedException(string callbackName, Exception ex) ++ { ++ if (_logger.IsEnabled(LogLevel.Warning)) ++ { ++ _logger.LogWarning($"Unexpected exception in {callbackName}: {ex}"); ++ } ++ } + } + +From 2f09d139bdb0a27ba0daf58bcb0d3fd75ea49cfe Mon Sep 17 00:00:00 2001 +From: Azat Mukhametshin +Date: Wed, 1 Jul 2026 23:51:07 +0200 +Subject: [PATCH 19/32] Validate GitHubActionsReport in MSBuild known-extension + registration test + +The known-extension registration acceptance test covered AzureDevOpsReport but not +the new GitHubActionsReport, leaving the extension's MSBuild self-registration hook +unvalidated. Add it (alphabetically): the package reference + version patch in the +generated asset, a --report-gh --help assertion, and an assertion that +Microsoft.Testing.Extensions.GitHubActionsReport.TestingPlatformBuilderHook.AddExtensions +is emitted into the generated SelfRegisteredExtensions source. + +Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> +--- + .../MSBuild.KnownExtensionRegistration.cs | 4 ++++ + 1 file changed, 4 insertions(+) + +diff --git a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/MSBuild.KnownExtensionRegistration.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/MSBuild.KnownExtensionRegistration.cs +index 5f6bfed1d9..7783ac1927 100644 +--- a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/MSBuild.KnownExtensionRegistration.cs ++++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/MSBuild.KnownExtensionRegistration.cs +@@ -22,6 +22,7 @@ public async Task Microsoft_Testing_Platform_Extensions_ShouldBe_Correctly_Regis + .PatchCodeWithReplace("$TargetFrameworks$", tfm) + .PatchCodeWithReplace("$MicrosoftTestingPlatformVersion$", MicrosoftTestingPlatformVersion) + .PatchCodeWithReplace("$MicrosoftTestingExtensionsCtrfReportVersion$", MicrosoftTestingExtensionsCtrfReportVersion) ++ .PatchCodeWithReplace("$MicrosoftTestingExtensionsGitHubActionsReportVersion$", MicrosoftTestingExtensionsGitHubActionsReportVersion) + .PatchCodeWithReplace("$MicrosoftTestingExtensionsJUnitReportVersion$", MicrosoftTestingExtensionsJUnitReportVersion) + .PatchCodeWithReplace("$MicrosoftTestingExtensionsVideoRecorderVersion$", MicrosoftTestingExtensionsVideoRecorderVersion)); + DotnetMuxerResult result = await DotnetCli.RunAsync($"{(verb == Verb.publish ? $"publish -f {tfm}" : "build")} -c {compilationMode} -r {RID} {testAsset.TargetAssetPath} -v:n", cancellationToken: TestContext.CancellationToken); +@@ -34,6 +35,7 @@ public async Task Microsoft_Testing_Platform_Extensions_ShouldBe_Correctly_Regis + testHostResult.AssertOutputContains("--publish-azdo-run-name"); + testHostResult.AssertOutputContains("--publish-azdo-test-results"); + testHostResult.AssertOutputContains("--report-ctrf"); ++ testHostResult.AssertOutputContains("--report-gh"); + testHostResult.AssertOutputContains("--report-html"); + testHostResult.AssertOutputContains("--report-junit"); + testHostResult.AssertOutputContains("--report-trx"); +@@ -48,6 +50,7 @@ public async Task Microsoft_Testing_Platform_Extensions_ShouldBe_Correctly_Regis + Assert.Contains("Microsoft.Testing.Extensions.AzureDevOpsReport.TestingPlatformBuilderHook.AddExtensions", generatedSource.Text, generatedSource.Text); + Assert.Contains("Microsoft.Testing.Extensions.CrashDump.TestingPlatformBuilderHook.AddExtensions", generatedSource.Text, generatedSource.Text); + Assert.Contains("Microsoft.Testing.Extensions.CtrfReport.TestingPlatformBuilderHook.AddExtensions", generatedSource.Text, generatedSource.Text); ++ Assert.Contains("Microsoft.Testing.Extensions.GitHubActionsReport.TestingPlatformBuilderHook.AddExtensions", generatedSource.Text, generatedSource.Text); + Assert.Contains("Microsoft.Testing.Extensions.HangDump.TestingPlatformBuilderHook.AddExtensions", generatedSource.Text, generatedSource.Text); + Assert.Contains("Microsoft.Testing.Extensions.HotReload.TestingPlatformBuilderHook.AddExtensions", generatedSource.Text, generatedSource.Text); + Assert.Contains("Microsoft.Testing.Extensions.HtmlReport.TestingPlatformBuilderHook.AddExtensions", generatedSource.Text, generatedSource.Text); +@@ -112,6 +115,7 @@ public async Task TestingPlatformBuilderHook_With_Conflicting_Metadata_Fails_Bui + + + ++ + + + + +From e4804868113fdfd5ae8a7c5259ee16d69f7a1d5b Mon Sep 17 00:00:00 2001 +From: Azat Mukhametshin +Date: Thu, 2 Jul 2026 00:01:16 +0200 +Subject: [PATCH 20/32] Make GitHubActionsAnnotationReporter tests robust + (Amaury review #5) + +The two source-location tests hard-coded the physical line numbers of their throw +statements (line=19/40) and relied on the real repo checkout + SystemFileSystem, so +any edit above the throws or a relocated/packaged layout would break them. + +- Compute the throw line dynamically via a CaptureException helper + [CallerLineNumber] + so edits elsewhere in the file no longer shift a hard-coded expectation. +- Inject a mock IFileSystem (every file "exists") so resolution no longer depends on + the source file physically being present. +- Assert the resolved file suffix / line / escaping instead of a full checkout-coupled + absolute path. + +Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> +--- + .../GitHubActionsAnnotationReporterTests.cs | 78 +++++++++++-------- + 1 file changed, 45 insertions(+), 33 deletions(-) + +diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsAnnotationReporterTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsAnnotationReporterTests.cs +index 9e56d1bc23..3d31b66d75 100644 +--- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsAnnotationReporterTests.cs ++++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsAnnotationReporterTests.cs +@@ -5,58 +5,46 @@ + using Microsoft.Testing.Platform.Helpers; + using Microsoft.Testing.Platform.Logging; + ++using Moq; ++ + namespace Microsoft.Testing.Extensions.UnitTests; + + [TestClass] + public sealed class GitHubActionsAnnotationReporterTests + { + [TestMethod] +- public void GetErrorAnnotation_ReportsFirstExistingFileWithLineColTitleAndEscaping() ++ public void GetErrorAnnotation_ReportsResolvedFileWithLineColTitleAndEscaping() + { +- Exception error; +- try +- { +- throw new Exception("this is an error\nwith\rnewline"); +- } +- catch (Exception ex) +- { +- error = ex; +- } ++ Exception error = CaptureException("this is an error\nwith\rnewline", out int throwLine); + +- var logger = new NoopLogger(); +- string text = GitHubActionsAnnotationReporter.GetErrorAnnotation("MyNamespace.MyTest", null, error, GitHubActionsRepositoryRoot.FindGitRoot(), new SystemFileSystem(), logger); ++ string text = GitHubActionsAnnotationReporter.GetErrorAnnotation( ++ "MyNamespace.MyTest", explanation: null, error, GitHubActionsRepositoryRoot.FindGitRoot(), CreateFileSystemWhereEveryFileExists(), new NoopLogger()); + +- Assert.AreEqual( +- "::error file=test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsAnnotationReporterTests.cs,line=19,col=1,title=Test failed%3A MyNamespace.MyTest::this is an error%0Awith%0Dnewline", +- text); ++ // The line is computed dynamically (from where the throw actually executes) rather than hard-coded, and the ++ // file existence is mocked, so the assertion does not depend on this file's exact layout or the physical ++ // repo checkout. ++ Assert.IsTrue(text.StartsWith("::error file=", StringComparison.Ordinal), text); ++ Assert.Contains($"GitHubActionsAnnotationReporterTests.cs,line={throwLine},col=1,title=Test failed%3A MyNamespace.MyTest::", text); ++ Assert.IsTrue(text.EndsWith("this is an error%0Awith%0Dnewline", StringComparison.Ordinal), text); + } + + [TestMethod] + public void GetErrorAnnotation_PrefersExplanationOverExceptionMessage() + { +- Exception error; +- try +- { +- throw new Exception("exception message"); +- } +- catch (Exception ex) +- { +- error = ex; +- } ++ Exception error = CaptureException("exception message", out int throwLine); + +- var logger = new NoopLogger(); +- string text = GitHubActionsAnnotationReporter.GetErrorAnnotation("MyNamespace.MyTest", "Some custom reason\nwith\rnewline", error, GitHubActionsRepositoryRoot.FindGitRoot(), new SystemFileSystem(), logger); ++ string text = GitHubActionsAnnotationReporter.GetErrorAnnotation( ++ "MyNamespace.MyTest", "Some custom reason\nwith\rnewline", error, GitHubActionsRepositoryRoot.FindGitRoot(), CreateFileSystemWhereEveryFileExists(), new NoopLogger()); + +- Assert.AreEqual( +- "::error file=test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsAnnotationReporterTests.cs,line=40,col=1,title=Test failed%3A MyNamespace.MyTest::Some custom reason%0Awith%0Dnewline", +- text); ++ Assert.IsTrue(text.StartsWith("::error file=", StringComparison.Ordinal), text); ++ Assert.Contains($"GitHubActionsAnnotationReporterTests.cs,line={throwLine},col=1,title=Test failed%3A MyNamespace.MyTest::", text); ++ Assert.IsTrue(text.EndsWith("Some custom reason%0Awith%0Dnewline", StringComparison.Ordinal), text); + } + + [TestMethod] + public void GetErrorAnnotation_FallsBackToTitleOnly_WhenNoSourceLocation() + { +- var logger = new NoopLogger(); +- string text = GitHubActionsAnnotationReporter.GetErrorAnnotation("MyNamespace.MyTest", "boom", exception: null, repoRoot: null, new SystemFileSystem(), logger); ++ string text = GitHubActionsAnnotationReporter.GetErrorAnnotation("MyNamespace.MyTest", "boom", exception: null, repoRoot: null, CreateFileSystemWhereEveryFileExists(), new NoopLogger()); + + Assert.AreEqual("::error title=Test failed%3A MyNamespace.MyTest::boom", text); + } +@@ -64,12 +52,36 @@ public void GetErrorAnnotation_FallsBackToTitleOnly_WhenNoSourceLocation() + [TestMethod] + public void GetErrorAnnotation_UsesFallbackMessage_WhenNoExplanationOrException() + { +- var logger = new NoopLogger(); +- string text = GitHubActionsAnnotationReporter.GetErrorAnnotation("MyNamespace.MyTest", explanation: null, exception: null, repoRoot: null, new SystemFileSystem(), logger); ++ string text = GitHubActionsAnnotationReporter.GetErrorAnnotation("MyNamespace.MyTest", explanation: null, exception: null, repoRoot: null, CreateFileSystemWhereEveryFileExists(), new NoopLogger()); + + Assert.IsTrue(text.StartsWith("::error title=Test failed%3A MyNamespace.MyTest::", StringComparison.Ordinal), text); + } + ++ // Throws (and catches) an exception, reporting the exact line of the throw statement so tests can assert the ++ // resolved line without hard-coding a physical number that shifts whenever code above changes. ++ private static Exception CaptureException(string message, out int throwLine) ++ { ++ throwLine = 0; ++ try ++ { ++ throwLine = CurrentLine() + 1; ++ throw new Exception(message); ++ } ++ catch (Exception ex) ++ { ++ return ex; ++ } ++ } ++ ++ private static int CurrentLine([CallerLineNumber] int line = 0) => line; ++ ++ private static IFileSystem CreateFileSystemWhereEveryFileExists() ++ { ++ var fileSystem = new Mock(); ++ fileSystem.Setup(f => f.ExistFile(It.IsAny())).Returns(true); ++ return fileSystem.Object; ++ } ++ + private sealed class NoopLogger : ILogger + { + public bool IsEnabled(LogLevel logLevel) => false; + +From db13b15be0262b2db1363ec4fc23fc403d93a27b Mon Sep 17 00:00:00 2001 +From: Azat Mukhametshin +Date: Thu, 2 Jul 2026 00:02:08 +0200 +Subject: [PATCH 21/32] Add Changelog-Platform entry for GitHubActionsReport + (Amaury review #9) + +Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> +--- + docs/Changelog-Platform.md | 1 + + 1 file changed, 1 insertion(+) + +diff --git a/docs/Changelog-Platform.md b/docs/Changelog-Platform.md +index 399c845bea..68cafac437 100644 +--- a/docs/Changelog-Platform.md ++++ b/docs/Changelog-Platform.md +@@ -43,6 +43,7 @@ See full log [of v4.2.3...v4.3.0](https://github.com/microsoft/testfx/compare/v4 + * Add experimental `ITestHostLauncher` extension point and `Microsoft.Testing.Extensions.PackagedApp` reference extension for packaged app test host deployment by @Evangelink in [#9454](https://github.com/microsoft/testfx/pull/9454) + * Forward Azure DevOps logging commands over the dotnet test pipe for multi-assembly test runs by @Evangelink in [#9463](https://github.com/microsoft/testfx/pull/9463) + * Add `PropertyBag.FirstOrDefault()` for efficient single-property lookup without per-call array allocation by @Evangelink in [#9488](https://github.com/microsoft/testfx/pull/9488) ++* Add experimental `Microsoft.Testing.Extensions.GitHubActionsReport` extension emitting GitHub Actions workflow commands (per-assembly log groups, failure annotations, job summary, slow-test notices) by @azat-msft in [#9541](https://github.com/microsoft/testfx/pull/9541) + + ### Fixed + + +From 903593b953fda1b77f8fc784f09aa497955c0649 Mon Sep 17 00:00:00 2001 +From: Azat Mukhametshin +Date: Thu, 2 Jul 2026 00:03:04 +0200 +Subject: [PATCH 22/32] Document GitHubActionsRepositoryRoot process-lifetime + cache (Amaury review #8) + +Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> +--- + .../GitHubActionsRepositoryRoot.cs | 3 +++ + 1 file changed, 3 insertions(+) + +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsRepositoryRoot.cs b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsRepositoryRoot.cs +index 0ba6abcd53..bc9acde021 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsRepositoryRoot.cs ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsRepositoryRoot.cs +@@ -14,6 +14,9 @@ namespace Microsoft.Testing.Extensions.GitHubActionsReport; + /// + internal static class GitHubActionsRepositoryRoot + { ++ // Process-lifetime cache of the discovered git root. Written without synchronization on purpose: the value ++ // is idempotent (always derived from the constant AppContext.BaseDirectory), so a benign race can at most ++ // recompute the same path. It is intentionally never reset for the lifetime of the process. + private static string? s_cachedGitRoot; + + public static string? Resolve(IEnvironment environment) + +From 85cddabd465eb525220ee38da50f9e72edcf6c75 Mon Sep 17 00:00:00 2001 +From: Azat Mukhametshin +Date: Thu, 2 Jul 2026 00:03:19 +0200 +Subject: [PATCH 23/32] Explain FindGitRoot divergence from RootFinder (Amaury + review #11) + +Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> +--- + .../GitHubActionsRepositoryRoot.cs | 4 ++++ + 1 file changed, 4 insertions(+) + +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsRepositoryRoot.cs b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsRepositoryRoot.cs +index bc9acde021..28c3378f91 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsRepositoryRoot.cs ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsRepositoryRoot.cs +@@ -29,6 +29,10 @@ internal static class GitHubActionsRepositoryRoot + + internal static /* for testing */ string? FindGitRoot() + { ++ // This intentionally mirrors the test-infrastructure RootFinder.Find() walk (from AppContext.BaseDirectory ++ // up to the drive root, looking for a '.git' directory or worktree file) but returns null instead of ++ // throwing when nothing is found, so a reporter running outside a git checkout degrades to "no source ++ // location" rather than failing. RootFinder also lives in test utilities, so it is deliberately not reused. + if (s_cachedGitRoot is not null) + { + return s_cachedGitRoot; + +From 9d8753b572a0a45f93060bcda4c006a919337e4c Mon Sep 17 00:00:00 2001 +From: Azat Mukhametshin +Date: Thu, 2 Jul 2026 00:03:58 +0200 +Subject: [PATCH 24/32] Clarify GetThresholdSeconds option re-read is + intentional (Amaury review #10) + +The slow-test threshold is validated by the command-line provider and re-read here. +Document that this mirrors the sibling AzureDevOpsSlowTestReporter (which reads its +options straight from ICommandLineOptions) rather than threading a parsed value +through, keeping the two reporters consistent. + +Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> +--- + .../GitHubActionsSlowTestReporter.cs | 3 +++ + 1 file changed, 3 insertions(+) + +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsSlowTestReporter.cs b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsSlowTestReporter.cs +index 6e7885191d..0954fa7672 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsSlowTestReporter.cs ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsSlowTestReporter.cs +@@ -66,6 +66,9 @@ protected override Task EmitSlowTestAsync(string testName, TimeSpan elapsed, Can + GitHubActionsEscaper.EscapeData(message)); + } + ++ // Re-reads the already-provider-validated threshold option (the CLI provider guarantees a parseable ++ // positive integer). This mirrors the sibling AzureDevOpsSlowTestReporter, which likewise reads its ++ // history options straight from ICommandLineOptions rather than threading a parsed value through. + private static int GetThresholdSeconds(ICommandLineOptions commandLineOptions) + => commandLineOptions.TryGetOptionArgumentList(GitHubActionsCommandLineOptions.GitHubActionsSlowTestThreshold, out string[]? arguments) + && arguments is [string value] + +From 2febe395366f2dca928454170c7abdc2e36aa243 Mon Sep 17 00:00:00 2001 +From: Azat Mukhametshin +Date: Thu, 2 Jul 2026 12:16:40 +0200 +Subject: [PATCH 25/32] Require the master switch and env var be set to true + for the extension to run + +--- + .../GitHubActionsFeature.cs | 4 ++-- + 1 file changed, 2 insertions(+), 2 deletions(-) + +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsFeature.cs b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsFeature.cs +index b53168ccc5..5ff9bc7295 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsFeature.cs ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsFeature.cs +@@ -1,4 +1,4 @@ +-// Copyright (c) Microsoft Corporation. All rights reserved. ++// Copyright (c) Microsoft Corporation. All rights reserved. + // Licensed under the MIT license. See LICENSE file in the project root for full license information. + + using Microsoft.Testing.Platform.CommandLine; +@@ -19,7 +19,7 @@ public static bool IsRunningOnGitHubActions(IEnvironment environment) + + public static bool IsMasterEnabled(ICommandLineOptions commandLine, IEnvironment environment) + => IsRunningOnGitHubActions(environment) +- || commandLine.IsOptionSet(GitHubActionsCommandLineOptions.GitHubActionsOptionName); ++ && commandLine.IsOptionSet(GitHubActionsCommandLineOptions.GitHubActionsOptionName); + + public static bool IsKnobEnabled(ICommandLineOptions commandLine, string knobOptionName) + => !(commandLine.TryGetOptionArgumentList(knobOptionName, out string[]? arguments) + +From 64b3979a4d74a0bee5a5886be79753a48ccd2c27 Mon Sep 17 00:00:00 2001 +From: Azat Mukhametshin +Date: Thu, 2 Jul 2026 12:59:59 +0200 +Subject: [PATCH 26/32] Align GitHub Actions reporter tests with the + master-switch enablement requirement + +The extension now requires BOTH GITHUB_ACTIONS=true and the --report-gh master switch +to be enabled. Seed the master switch in the GitHubActionsReporter/SlowTestReporter test +helpers so the "enabled" tests reflect the new precondition (the env/knob behavior is +exercised on top of it). + +Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> +--- + .../GitHubActionsReporterTests.cs | 9 ++++++++- + .../GitHubActionsSlowTestReporterTests.cs | 9 ++++++++- + 2 files changed, 16 insertions(+), 2 deletions(-) + +diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsReporterTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsReporterTests.cs +index 52c79330c2..db857086c4 100644 +--- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsReporterTests.cs ++++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsReporterTests.cs +@@ -63,6 +63,13 @@ private static GitHubActionsReporter CreateReporter(bool githubActions, Dictiona + var environment = new Mock(); + environment.Setup(e => e.GetEnvironmentVariable("GITHUB_ACTIONS")).Returns(githubActions ? "true" : null); + ++ // The extension is enabled only when both the GITHUB_ACTIONS env var and the --report-gh master switch ++ // are set, so always seed the master switch here; these tests exercise the env/knob behavior on top of it. ++ var commandLineOptions = new Dictionary(options, StringComparer.OrdinalIgnoreCase) ++ { ++ [GitHubActionsCommandLineOptions.GitHubActionsOptionName] = [], ++ }; ++ + var outputDevice = new Mock(); + outputDevice + .Setup(o => o.DisplayAsync(It.IsAny(), It.IsAny(), It.IsAny())) +@@ -77,7 +84,7 @@ private static GitHubActionsReporter CreateReporter(bool githubActions, Dictiona + loggerFactory.Setup(f => f.CreateLogger(It.IsAny())).Returns(logger.Object); + + return new GitHubActionsReporter( +- new TestCommandLineOptions(options), ++ new TestCommandLineOptions(commandLineOptions), + environment.Object, + outputDevice.Object, + moduleInfo.Object, +diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsSlowTestReporterTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsSlowTestReporterTests.cs +index e55f229ae5..f96bf6f46b 100644 +--- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsSlowTestReporterTests.cs ++++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsSlowTestReporterTests.cs +@@ -117,8 +117,15 @@ private static GitHubActionsSlowTestReporter CreateReporter(CapturingOutputDevic + Mock environmentMock = new(); + environmentMock.Setup(x => x.GetEnvironmentVariable("GITHUB_ACTIONS")).Returns(githubActions ? "true" : null); + ++ // The extension is enabled only when both the GITHUB_ACTIONS env var and the --report-gh master switch ++ // are set, so always seed the master switch here; these tests exercise the env/knob behavior on top of it. ++ Dictionary commandLineOptions = options is null ++ ? new(StringComparer.OrdinalIgnoreCase) ++ : new(options, StringComparer.OrdinalIgnoreCase); ++ commandLineOptions[GitHubActionsCommandLineOptions.GitHubActionsOptionName] = []; ++ + return new GitHubActionsSlowTestReporter( +- new FakeCommandLineOptions(options ?? []), ++ new FakeCommandLineOptions(commandLineOptions), + environmentMock.Object, + outputDevice, + new NonRunningTask(), + +From 50d8dfdd193e4de3fa886237355b4fe31537fd3f Mon Sep 17 00:00:00 2001 +From: Azat Mukhametshin +Date: Thu, 2 Jul 2026 13:00:13 +0200 +Subject: [PATCH 27/32] Reuse RootFinder in + GitHubActionsRepositoryRoot.FindGitRoot (Amaury review #11) + +Instead of duplicating the git-root walk, FindGitRoot now calls the shared +Microsoft.Testing.TestInfrastructure.RootFinder.Find() (linked into the project, as the +Azure DevOps extension already does) and translates its InvalidOperationException into +null so a reporter running outside a git checkout still degrades to "no source location". + +Because RootFinder is now compiled into both the AzureDevOpsReport and GitHubActionsReport +assemblies (both InternalsVisibleTo the extensions unit tests), the unqualified RootFinder +reference in the GitHub test files becomes ambiguous. Expose the GitHubActionsReport +reference under a `ghactions` extern alias and qualify the GitHub test files' usings so the +AzureDevOps tests keep binding to their copy. + +Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> +--- + .../GitHubActionsRepositoryRoot.cs | 36 +++++-------------- + ...ting.Extensions.GitHubActionsReport.csproj | 1 + + .../GitHubActionsAnnotationReporterTests.cs | 5 ++- + .../GitHubActionsCommandLineProviderTests.cs | 5 ++- + .../GitHubActionsEscaperTests.cs | 4 ++- + .../GitHubActionsReporterTests.cs | 5 ++- + .../GitHubActionsSlowTestReporterTests.cs | 5 +-- + .../GitHubActionsSummaryReporterTests.cs | 4 ++- + ...rosoft.Testing.Extensions.UnitTests.csproj | 1 + + 9 files changed, 32 insertions(+), 34 deletions(-) + +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsRepositoryRoot.cs b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsRepositoryRoot.cs +index 28c3378f91..be62d468a8 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsRepositoryRoot.cs ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsRepositoryRoot.cs +@@ -3,6 +3,7 @@ + + using Microsoft.Testing.Platform; + using Microsoft.Testing.Platform.Helpers; ++using Microsoft.Testing.TestInfrastructure; + + namespace Microsoft.Testing.Extensions.GitHubActionsReport; + +@@ -14,11 +15,6 @@ namespace Microsoft.Testing.Extensions.GitHubActionsReport; + /// + internal static class GitHubActionsRepositoryRoot + { +- // Process-lifetime cache of the discovered git root. Written without synchronization on purpose: the value +- // is idempotent (always derived from the constant AppContext.BaseDirectory), so a benign race can at most +- // recompute the same path. It is intentionally never reset for the lifetime of the process. +- private static string? s_cachedGitRoot; +- + public static string? Resolve(IEnvironment environment) + { + string? workspace = environment.GetEnvironmentVariable("GITHUB_WORKSPACE"); +@@ -29,32 +25,18 @@ internal static class GitHubActionsRepositoryRoot + + internal static /* for testing */ string? FindGitRoot() + { +- // This intentionally mirrors the test-infrastructure RootFinder.Find() walk (from AppContext.BaseDirectory +- // up to the drive root, looking for a '.git' directory or worktree file) but returns null instead of +- // throwing when nothing is found, so a reporter running outside a git checkout degrades to "no source +- // location" rather than failing. RootFinder also lives in test utilities, so it is deliberately not reused. +- if (s_cachedGitRoot is not null) ++ // Reuse the shared RootFinder walk (from AppContext.BaseDirectory up to the drive root, looking for a ++ // '.git' directory or worktree file, with a process-lifetime cache) rather than duplicating it here. ++ // RootFinder.Find() throws when no repository is found; a reporter running outside a git checkout must ++ // instead degrade to "no source location", so translate that into null. ++ try + { +- return s_cachedGitRoot; ++ return RootFinder.Find(); + } +- +- string currentDirectory = AppContext.BaseDirectory; +- string rootDriveDirectory = Directory.GetDirectoryRoot(currentDirectory); +- while (!string.Equals(rootDriveDirectory, currentDirectory, StringComparison.Ordinal)) ++ catch (InvalidOperationException) + { +- string gitPath = Path.Combine(currentDirectory, ".git"); +- +- // When working with git worktrees, the .git is a file not a folder. +- if (Directory.Exists(gitPath) || File.Exists(gitPath)) +- { +- s_cachedGitRoot = currentDirectory + Path.DirectorySeparatorChar; +- return s_cachedGitRoot; +- } +- +- currentDirectory = Directory.GetParent(currentDirectory)!.ToString(); ++ return null; + } +- +- return null; + } + + private static string EnsureTrailingSeparator(string path) +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Microsoft.Testing.Extensions.GitHubActionsReport.csproj b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Microsoft.Testing.Extensions.GitHubActionsReport.csproj +index 190690a218..24a043e4ff 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Microsoft.Testing.Extensions.GitHubActionsReport.csproj ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Microsoft.Testing.Extensions.GitHubActionsReport.csproj +@@ -16,6 +16,7 @@ + + + ++ + + + +diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsAnnotationReporterTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsAnnotationReporterTests.cs +index 3d31b66d75..7d9d69cb6a 100644 +--- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsAnnotationReporterTests.cs ++++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsAnnotationReporterTests.cs +@@ -1,7 +1,10 @@ + // Copyright (c) Microsoft Corporation. All rights reserved. + // Licensed under the MIT license. See LICENSE file in the project root for full license information. + +-using Microsoft.Testing.Extensions.GitHubActionsReport; ++extern alias ghactions; ++ ++using ghactions::Microsoft.Testing.Extensions.GitHubActionsReport; ++ + using Microsoft.Testing.Platform.Helpers; + using Microsoft.Testing.Platform.Logging; + +diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsCommandLineProviderTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsCommandLineProviderTests.cs +index d9de66dffc..a4ecda8af7 100644 +--- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsCommandLineProviderTests.cs ++++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsCommandLineProviderTests.cs +@@ -1,7 +1,10 @@ + // Copyright (c) Microsoft Corporation. All rights reserved. + // Licensed under the MIT license. See LICENSE file in the project root for full license information. + +-using Microsoft.Testing.Extensions.GitHubActionsReport; ++extern alias ghactions; ++ ++using ghactions::Microsoft.Testing.Extensions.GitHubActionsReport; ++ + using Microsoft.Testing.Platform.CommandLine; + using Microsoft.Testing.Platform.Extensions.CommandLine; + +diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsEscaperTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsEscaperTests.cs +index a541050402..bd2afb4b46 100644 +--- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsEscaperTests.cs ++++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsEscaperTests.cs +@@ -1,7 +1,9 @@ + // Copyright (c) Microsoft Corporation. All rights reserved. + // Licensed under the MIT license. See LICENSE file in the project root for full license information. + +-using Microsoft.Testing.Extensions.GitHubActionsReport; ++extern alias ghactions; ++ ++using ghactions::Microsoft.Testing.Extensions.GitHubActionsReport; + + namespace Microsoft.Testing.Extensions.UnitTests; + +diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsReporterTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsReporterTests.cs +index db857086c4..c2c4e7c098 100644 +--- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsReporterTests.cs ++++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsReporterTests.cs +@@ -1,7 +1,10 @@ + // Copyright (c) Microsoft Corporation. All rights reserved. + // Licensed under the MIT license. See LICENSE file in the project root for full license information. + +-using Microsoft.Testing.Extensions.GitHubActionsReport; ++extern alias ghactions; ++ ++using ghactions::Microsoft.Testing.Extensions.GitHubActionsReport; ++ + using Microsoft.Testing.Extensions.UnitTests.Helpers; + using Microsoft.Testing.Platform.Extensions.OutputDevice; + using Microsoft.Testing.Platform.Extensions.TestHost; +diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsSlowTestReporterTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsSlowTestReporterTests.cs +index f96bf6f46b..bdee744e2b 100644 +--- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsSlowTestReporterTests.cs ++++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsSlowTestReporterTests.cs +@@ -1,9 +1,10 @@ + // Copyright (c) Microsoft Corporation. All rights reserved. + // Licensed under the MIT license. See LICENSE file in the project root for full license information. + +-using System.Diagnostics.CodeAnalysis; ++extern alias ghactions; ++ ++using ghactions::Microsoft.Testing.Extensions.GitHubActionsReport; + +-using Microsoft.Testing.Extensions.GitHubActionsReport; + using Microsoft.Testing.Platform.CommandLine; + using Microsoft.Testing.Platform.Extensions.Messages; + using Microsoft.Testing.Platform.Extensions.OutputDevice; +diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsSummaryReporterTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsSummaryReporterTests.cs +index 1f6cdc01d1..4fa02ae536 100644 +--- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsSummaryReporterTests.cs ++++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsSummaryReporterTests.cs +@@ -1,7 +1,9 @@ + // Copyright (c) Microsoft Corporation. All rights reserved. + // Licensed under the MIT license. See LICENSE file in the project root for full license information. + +-using Microsoft.Testing.Extensions.GitHubActionsReport; ++extern alias ghactions; ++ ++using ghactions::Microsoft.Testing.Extensions.GitHubActionsReport; + + namespace Microsoft.Testing.Extensions.UnitTests; + +diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/Microsoft.Testing.Extensions.UnitTests.csproj b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/Microsoft.Testing.Extensions.UnitTests.csproj +index 1bc17b4d2d..488ab3aa63 100644 +--- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/Microsoft.Testing.Extensions.UnitTests.csproj ++++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/Microsoft.Testing.Extensions.UnitTests.csproj +@@ -37,6 +37,7 @@ + TargetFramework=netstandard2.0 + + ++ ghactions + TargetFramework=netstandard2.0 + + + +From 8b85b38807f2372328d59d58f2a3b2230c366547 Mon Sep 17 00:00:00 2001 +From: Copilot App <223556219+Copilot@users.noreply.github.com> +Date: Fri, 3 Jul 2026 12:57:29 +0200 +Subject: [PATCH 28/32] Address PR review feedback on GitHubActionsReport + +- Share SharedExtensionHelpers/TestNodeIdentity.cs instead of shipping a + project-local duplicate. +- Resolve the summary reporter's test name via TestNodeIdentity (prefers + TestMethodIdentifierProperty) so names match the annotation/slow-test + surfaces; drop the duplicated FQN property-key constant. +- Open GITHUB_STEP_SUMMARY with FileShare.ReadWrite so parallel assemblies + don't drop the job summary. +- Use PropertyBag.FirstOrDefault instead of SingleOrDefault for the state + property (summary reporter and shared SlowTestReporterBase). +- Route buildTransitive props through build/ to match the sibling + extensions' convention. +- Document the group-reporter builder-hook GUID / endgroup-last ordering. +- Fix the GitHubActionsFeature activation doc (and, not or). +- Add annotation tests for the deterministic '/_/' build-root remap and + assertion-frame skipping. + +Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> +--- + .../GitHubActionsFeature.cs | 8 +-- + .../GitHubActionsSummaryReporter.cs | 21 +++---- + ...ting.Extensions.GitHubActionsReport.csproj | 1 + + .../TestNodeIdentity.cs | 57 ------------------- + ...sting.Extensions.GitHubActionsReport.props | 6 ++ + ...sting.Extensions.GitHubActionsReport.props | 2 +- + .../SlowTestReporterBase.cs | 2 +- + .../GitHubActionsAnnotationReporterTests.cs | 38 +++++++++++++ + 8 files changed, 60 insertions(+), 75 deletions(-) + delete mode 100644 src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/TestNodeIdentity.cs + +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsFeature.cs b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsFeature.cs +index 5ff9bc7295..8f551e0fd7 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsFeature.cs ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsFeature.cs +@@ -7,10 +7,10 @@ + namespace Microsoft.Testing.Extensions.GitHubActionsReport; + + /// +-/// Shared activation logic for the GitHub Actions reporters. The extension auto-activates when running +-/// on GitHub Actions (GITHUB_ACTIONS=true) or when the --report-gh master switch is set; +-/// each individual feature is then on by default but can be turned off with its --report-gh-* +-/// knob set to off. ++/// Shared activation logic for the GitHub Actions reporters. The extension activates only when running ++/// on GitHub Actions (GITHUB_ACTIONS=true) and the --report-gh master switch is set; each ++/// individual feature is then on by default but can be turned off with its --report-gh-* knob set ++/// to off. + /// + internal static class GitHubActionsFeature + { +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsSummaryReporter.cs b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsSummaryReporter.cs +index d8e6be6b39..9a565a2a81 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsSummaryReporter.cs ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsSummaryReporter.cs +@@ -27,7 +27,6 @@ internal sealed class GitHubActionsSummaryReporter : + IOutputDeviceDataProducer + { + private const string StepSummaryEnvironmentVariable = "GITHUB_STEP_SUMMARY"; +- private const string FullyQualifiedNamePropertyKey = "vstest.TestCase.FullyQualifiedName"; + private const int MaxFailures = 20; + private const int MaxSlowestTests = 10; + +@@ -98,7 +97,7 @@ public Task ConsumeAsync(IDataProducer dataProducer, IData value, CancellationTo + return Task.CompletedTask; + } + +- TestNodeStateProperty? state = update.TestNode.Properties.SingleOrDefault(); ++ TestNodeStateProperty? state = update.TestNode.Properties.FirstOrDefault(); + TerminalKind kind = GetTerminalKind(state); + if (kind == TerminalKind.NotTerminal) + { +@@ -108,23 +107,21 @@ public Task ConsumeAsync(IDataProducer dataProducer, IData value, CancellationTo + string uid = update.TestNode.Uid; + string displayName = update.TestNode.DisplayName; + ++ // Resolve the stable, fully-qualified name the same way the annotation and slow-test reporters do ++ // (preferring TestMethodIdentifierProperty) so a given test renders identically across all three surfaces. ++ string fullyQualifiedName = TestNodeIdentity.GetTestName(update.TestNode); ++ + TimingProperty? timing = null; +- string? fqnValue = null; + PropertyBag.PropertyBagEnumerator enumerator = update.TestNode.Properties.GetStructEnumerator(); + while (enumerator.MoveNext()) + { +- switch (enumerator.Current) ++ if (enumerator.Current is TimingProperty t) + { +- case TimingProperty t: +- timing = t; +- break; +- case SerializableKeyValuePairStringProperty kv when kv.Key == FullyQualifiedNamePropertyKey && fqnValue is null: +- fqnValue = kv.Value; +- break; ++ timing = t; ++ break; + } + } + +- string fullyQualifiedName = fqnValue ?? displayName; + TimeSpan duration = timing?.GlobalTiming.Duration ?? TimeSpan.Zero; + + lock (_stateLock) +@@ -178,7 +175,7 @@ public async Task OnTestSessionFinishingAsync(ITestSessionContext testSessionCon + + try + { +- using IFileStream stream = _fileSystem.NewFileStream(path!, FileMode.Append, FileAccess.Write, FileShare.Read); ++ using IFileStream stream = _fileSystem.NewFileStream(path!, FileMode.Append, FileAccess.Write, FileShare.ReadWrite); + using var writer = new StreamWriter(stream.Stream, new UTF8Encoding(false)); + await writer.WriteAsync(markdown).ConfigureAwait(false); + } +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Microsoft.Testing.Extensions.GitHubActionsReport.csproj b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Microsoft.Testing.Extensions.GitHubActionsReport.csproj +index 24a043e4ff..2f7a643746 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Microsoft.Testing.Extensions.GitHubActionsReport.csproj ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Microsoft.Testing.Extensions.GitHubActionsReport.csproj +@@ -16,6 +16,7 @@ + + + ++ + + + +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/TestNodeIdentity.cs b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/TestNodeIdentity.cs +deleted file mode 100644 +index 5e0c99a490..0000000000 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/TestNodeIdentity.cs ++++ /dev/null +@@ -1,57 +0,0 @@ +-// Copyright (c) Microsoft Corporation. All rights reserved. +-// Licensed under the MIT license. See LICENSE file in the project root for full license information. +- +-using Microsoft.Testing.Platform; +-using Microsoft.Testing.Platform.Extensions.Messages; +- +-namespace Microsoft.Testing.Extensions.GitHubActionsReport; +- +-internal static class TestNodeIdentity +-{ +- private const string FullyQualifiedNamePropertyKey = "vstest.TestCase.FullyQualifiedName"; +- +- /// +- /// Resolves the stable, fully-qualified test name for a . Falls back to the +- /// display name when the fully-qualified name property is unavailable. +- /// +- /// +- /// The identity is resolved in the same priority order used by the TRX and VSTest-bridge converters: +- /// +- /// — the canonical, ECMA-335 compliant identity added by +- /// frameworks running natively on the platform. +- /// The non-standard vstest.TestCase.FullyQualifiedName property (only present for tests flowing +- /// through the VSTest bridge in server mode). +- /// The display name, as a last resort. +- /// +- /// In practice a given carries at most one of the first two properties, so the walk order does +- /// not matter; we still prefer when both are somehow present. +- /// +- public static string GetTestName(TestNode testNode) +- { +- // Walk the PropertyBag once with the zero-allocation struct enumerator, avoiding the LINQ +- // iterator/boxed-enumerator allocations that OfType().FirstOrDefault(...) would incur. +- // Short-circuit as soon as the preferred TestMethodIdentifierProperty is found; otherwise +- // remember the first VSTest fully-qualified name as a fallback. +- string? fullyQualifiedName = null; +- using PropertyBag.PropertyBagEnumerator enumerator = testNode.Properties.GetStructEnumerator(); +- while (enumerator.MoveNext()) +- { +- switch (enumerator.Current) +- { +- case TestMethodIdentifierProperty testMethodIdentifier: +- return FormatFullyQualifiedName(testMethodIdentifier); +- +- case SerializableKeyValuePairStringProperty { Key: FullyQualifiedNamePropertyKey } kvp when fullyQualifiedName is null: +- fullyQualifiedName = kvp.Value; +- break; +- } +- } +- +- return fullyQualifiedName ?? testNode.DisplayName; +- } +- +- private static string FormatFullyQualifiedName(TestMethodIdentifierProperty testMethodIdentifier) +- => RoslynString.IsNullOrEmpty(testMethodIdentifier.Namespace) +- ? $"{testMethodIdentifier.TypeName}.{testMethodIdentifier.MethodName}" +- : $"{testMethodIdentifier.Namespace}.{testMethodIdentifier.TypeName}.{testMethodIdentifier.MethodName}"; +-} +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/buildMultiTargeting/Microsoft.Testing.Extensions.GitHubActionsReport.props b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/buildMultiTargeting/Microsoft.Testing.Extensions.GitHubActionsReport.props +index ea74c713e0..fa1c9640a6 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/buildMultiTargeting/Microsoft.Testing.Extensions.GitHubActionsReport.props ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/buildMultiTargeting/Microsoft.Testing.Extensions.GitHubActionsReport.props +@@ -1,6 +1,12 @@ +  + + ++ + + Microsoft.Testing.Extensions.GitHubActionsReport + Microsoft.Testing.Extensions.GitHubActionsReport.TestingPlatformBuilderHook +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/buildTransitive/Microsoft.Testing.Extensions.GitHubActionsReport.props b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/buildTransitive/Microsoft.Testing.Extensions.GitHubActionsReport.props +index b640aa2cd6..c9ec314bf6 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/buildTransitive/Microsoft.Testing.Extensions.GitHubActionsReport.props ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/buildTransitive/Microsoft.Testing.Extensions.GitHubActionsReport.props +@@ -1,3 +1,3 @@ +  +- ++ + +diff --git a/src/Platform/SharedExtensionHelpers/SlowTestReporterBase.cs b/src/Platform/SharedExtensionHelpers/SlowTestReporterBase.cs +index 5eb69ee799..8556840d86 100644 +--- a/src/Platform/SharedExtensionHelpers/SlowTestReporterBase.cs ++++ b/src/Platform/SharedExtensionHelpers/SlowTestReporterBase.cs +@@ -111,7 +111,7 @@ public Task ConsumeAsync(IDataProducer dataProducer, IData value, CancellationTo + } + + string uid = update.TestNode.Uid; +- TestNodeStateProperty? state = update.TestNode.Properties.SingleOrDefault(); ++ TestNodeStateProperty? state = update.TestNode.Properties.FirstOrDefault(); + if (state is InProgressTestNodeStateProperty) + { + string testName = GetTestName(update.TestNode); +diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsAnnotationReporterTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsAnnotationReporterTests.cs +index 7d9d69cb6a..13c4648af7 100644 +--- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsAnnotationReporterTests.cs ++++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsAnnotationReporterTests.cs +@@ -52,6 +52,33 @@ public void GetErrorAnnotation_FallsBackToTitleOnly_WhenNoSourceLocation() + Assert.AreEqual("::error title=Test failed%3A MyNamespace.MyTest::boom", text); + } + ++ [TestMethod] ++ public void GetErrorAnnotation_RemapsDeterministicBuildRootPathToWorkspaceRelative() ++ { ++ // A frame emitted from a deterministic (source-linked) build carries the '/_/' root marker; the reporter ++ // must strip it and produce a forward-slash workspace-relative path regardless of the repo root value. ++ var exception = new StackTraceException(" at Contoso.Calc.Add() in /_/src/Calc.cs:line 12"); ++ ++ string text = GitHubActionsAnnotationReporter.GetErrorAnnotation( ++ "Contoso.CalcTests.Add", "boom", exception, repoRoot: "/repo/", CreateFileSystemWhereEveryFileExists(), new NoopLogger()); ++ ++ Assert.AreEqual("::error file=src/Calc.cs,line=12,col=1,title=Test failed%3A Contoso.CalcTests.Add::boom", text); ++ } ++ ++ [TestMethod] ++ public void GetErrorAnnotation_SkipsAssertionFramesAndAnnotatesUserCallSite() ++ { ++ // The top frame is an MSTest assertion implementation and must be skipped in favour of the user's call site. ++ var exception = new StackTraceException( ++ " at Microsoft.VisualStudio.TestTools.UnitTesting.Assert.Fail(string message) in /_/assert/Assert.cs:line 1\n" ++ + " at Contoso.MyTests.TheTest() in /_/src/MyTests.cs:line 7"); ++ ++ string text = GitHubActionsAnnotationReporter.GetErrorAnnotation( ++ "Contoso.MyTests.TheTest", "nope", exception, repoRoot: "/repo/", CreateFileSystemWhereEveryFileExists(), new NoopLogger()); ++ ++ Assert.AreEqual("::error file=src/MyTests.cs,line=7,col=1,title=Test failed%3A Contoso.MyTests.TheTest::nope", text); ++ } ++ + [TestMethod] + public void GetErrorAnnotation_UsesFallbackMessage_WhenNoExplanationOrException() + { +@@ -85,6 +112,17 @@ private static IFileSystem CreateFileSystemWhereEveryFileExists() + return fileSystem.Object; + } + ++ // Exception whose StackTrace is a caller-supplied synthetic string, letting tests exercise the ++ // frame-parsing/path-remapping branches deterministically without relying on real PDB-derived paths. ++ private sealed class StackTraceException : Exception ++ { ++ private readonly string _stackTrace; ++ ++ public StackTraceException(string stackTrace) => _stackTrace = stackTrace; ++ ++ public override string? StackTrace => _stackTrace; ++ } ++ + private sealed class NoopLogger : ILogger + { + public bool IsEnabled(LogLevel logLevel) => false; + +From f27fbff38471a9e594bf3402a5ae60d54de8e07a Mon Sep 17 00:00:00 2001 +From: Copilot App <223556219+Copilot@users.noreply.github.com> +Date: Fri, 3 Jul 2026 13:23:39 +0200 +Subject: [PATCH 29/32] Use shared CLI helpers for GitHubActionsReport options + +- Validate the on/off knobs (--report-gh-groups/-annotations/-step-summary/ + -slow-test-notices) with CommandLineOptionArgumentValidator so they accept + on/off, true/false, enable/disable and 1/0; read the disabled state via + IsOffValue. +- Parse --report-gh-slow-test-threshold with the shared TimeSpanParser + (default unit seconds) so values like '60', '90s', '2m' and '1.5h' work; + Compile-link TimeSpanParser.cs like HangDump does (it is an [Embedded] + type hidden across assemblies). +- Update option descriptions / invalid-value messages (mirroring the + CrashDump wording), regenerate all .xlf locales, and update the --help + and --info acceptance expectations. +- Add provider tests for the boolean aliases and duration formats. + +Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> +--- + .../GitHubActionsCommandLineOptions.cs | 2 - + .../GitHubActionsCommandLineProvider.cs | 7 ++- + .../GitHubActionsFeature.cs | 2 +- + .../GitHubActionsSlowTestReporter.cs | 17 ++++---- + ...ting.Extensions.GitHubActionsReport.csproj | 1 + + .../Resources/GitHubActionsResources.resx | 26 +++++------ + .../xlf/GitHubActionsResources.cs.xlf | 42 +++++++++--------- + .../xlf/GitHubActionsResources.de.xlf | 42 +++++++++--------- + .../xlf/GitHubActionsResources.es.xlf | 42 +++++++++--------- + .../xlf/GitHubActionsResources.fr.xlf | 42 +++++++++--------- + .../xlf/GitHubActionsResources.it.xlf | 42 +++++++++--------- + .../xlf/GitHubActionsResources.ja.xlf | 42 +++++++++--------- + .../xlf/GitHubActionsResources.ko.xlf | 42 +++++++++--------- + .../xlf/GitHubActionsResources.pl.xlf | 42 +++++++++--------- + .../xlf/GitHubActionsResources.pt-BR.xlf | 42 +++++++++--------- + .../xlf/GitHubActionsResources.ru.xlf | 42 +++++++++--------- + .../xlf/GitHubActionsResources.tr.xlf | 42 +++++++++--------- + .../xlf/GitHubActionsResources.zh-Hans.xlf | 42 +++++++++--------- + .../xlf/GitHubActionsResources.zh-Hant.xlf | 42 +++++++++--------- + .../HelpInfoAllExtensionsTests.cs | 20 ++++----- + .../GitHubActionsCommandLineProviderTests.cs | 43 +++++++++++++++++++ + 21 files changed, 354 insertions(+), 310 deletions(-) + +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsCommandLineOptions.cs b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsCommandLineOptions.cs +index b64ed542e7..3bab57e7f2 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsCommandLineOptions.cs ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsCommandLineOptions.cs +@@ -11,8 +11,6 @@ internal static class GitHubActionsCommandLineOptions + public const string GitHubActionsStepSummary = "report-gh-step-summary"; + public const string GitHubActionsSlowTestNotices = "report-gh-slow-test-notices"; + public const string GitHubActionsSlowTestThreshold = "report-gh-slow-test-threshold"; +- public const string OptionOn = "on"; +- public const string OptionOff = "off"; + + public const int SlowTestThresholdDefaultSeconds = 60; + } +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsCommandLineProvider.cs b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsCommandLineProvider.cs +index aef3359baf..e9bcd78ebf 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsCommandLineProvider.cs ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsCommandLineProvider.cs +@@ -5,13 +5,12 @@ + using Microsoft.Testing.Platform.CommandLine; + using Microsoft.Testing.Platform.Extensions; + using Microsoft.Testing.Platform.Extensions.CommandLine; ++using Microsoft.Testing.Platform.Helpers; + + namespace Microsoft.Testing.Extensions.GitHubActionsReport; + + internal sealed class GitHubActionsCommandLineProvider : CommandLineOptionsProviderBase + { +- private static readonly string[] OnOffOptions = [GitHubActionsCommandLineOptions.OptionOn, GitHubActionsCommandLineOptions.OptionOff]; +- + public GitHubActionsCommandLineProvider() + : base( + nameof(GitHubActionsCommandLineProvider), +@@ -33,10 +32,10 @@ public override Task ValidateOptionArgumentsAsync(CommandLineO + => commandOption.Name switch + { + GitHubActionsCommandLineOptions.GitHubActionsGroups or GitHubActionsCommandLineOptions.GitHubActionsAnnotations or GitHubActionsCommandLineOptions.GitHubActionsStepSummary or GitHubActionsCommandLineOptions.GitHubActionsSlowTestNotices +- when !OnOffOptions.Contains(arguments[0], StringComparer.OrdinalIgnoreCase) ++ when !CommandLineOptionArgumentValidator.IsValidBooleanArgument(arguments[0]) + => ValidationResult.InvalidTask(string.Format(CultureInfo.InvariantCulture, GitHubActionsResources.InvalidOnOffValue, arguments[0])), + GitHubActionsCommandLineOptions.GitHubActionsSlowTestThreshold +- when !(int.TryParse(arguments[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out int seconds) && seconds >= 1) ++ when !(TimeSpanParser.TryParse(arguments[0], TimeSpanDefaultUnit.Seconds, out TimeSpan threshold) && threshold > TimeSpan.Zero) + => ValidationResult.InvalidTask(string.Format(CultureInfo.InvariantCulture, GitHubActionsResources.InvalidSlowTestThreshold, arguments[0])), + _ => ValidationResult.ValidTask, + }; +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsFeature.cs b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsFeature.cs +index 8f551e0fd7..74d74373ae 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsFeature.cs ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsFeature.cs +@@ -24,7 +24,7 @@ public static bool IsMasterEnabled(ICommandLineOptions commandLine, IEnvironment + public static bool IsKnobEnabled(ICommandLineOptions commandLine, string knobOptionName) + => !(commandLine.TryGetOptionArgumentList(knobOptionName, out string[]? arguments) + && arguments is [string value] +- && string.Equals(value, GitHubActionsCommandLineOptions.OptionOff, StringComparison.OrdinalIgnoreCase)); ++ && CommandLineOptionArgumentValidator.IsOffValue(value)); + + public static bool IsEnabled(ICommandLineOptions commandLine, IEnvironment environment, string knobOptionName) + => IsMasterEnabled(commandLine, environment) && IsKnobEnabled(commandLine, knobOptionName); +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsSlowTestReporter.cs b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsSlowTestReporter.cs +index 0954fa7672..24a0a30f92 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsSlowTestReporter.cs ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsSlowTestReporter.cs +@@ -29,7 +29,7 @@ public GitHubActionsSlowTestReporter( + : base(outputDevice, task, clock, loggerFactory.CreateLogger()) + { + _isEnabled = GitHubActionsFeature.IsEnabled(commandLineOptions, environment, GitHubActionsCommandLineOptions.GitHubActionsSlowTestNotices); +- _threshold = TimeSpan.FromSeconds(GetThresholdSeconds(commandLineOptions)); ++ _threshold = GetThreshold(commandLineOptions); + } + + public override string Uid => nameof(GitHubActionsSlowTestReporter); +@@ -67,13 +67,14 @@ protected override Task EmitSlowTestAsync(string testName, TimeSpan elapsed, Can + } + + // Re-reads the already-provider-validated threshold option (the CLI provider guarantees a parseable +- // positive integer). This mirrors the sibling AzureDevOpsSlowTestReporter, which likewise reads its +- // history options straight from ICommandLineOptions rather than threading a parsed value through. +- private static int GetThresholdSeconds(ICommandLineOptions commandLineOptions) ++ // positive duration). Accepts a bare number of seconds or a value with a unit suffix (e.g. '90s', '2m'). ++ // This mirrors the sibling AzureDevOpsSlowTestReporter, which likewise reads its history options straight ++ // from ICommandLineOptions rather than threading a parsed value through. ++ private static TimeSpan GetThreshold(ICommandLineOptions commandLineOptions) + => commandLineOptions.TryGetOptionArgumentList(GitHubActionsCommandLineOptions.GitHubActionsSlowTestThreshold, out string[]? arguments) + && arguments is [string value] +- && int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int seconds) +- && seconds >= 1 +- ? seconds +- : GitHubActionsCommandLineOptions.SlowTestThresholdDefaultSeconds; ++ && TimeSpanParser.TryParse(value, TimeSpanDefaultUnit.Seconds, out TimeSpan threshold) ++ && threshold > TimeSpan.Zero ++ ? threshold ++ : TimeSpan.FromSeconds(GitHubActionsCommandLineOptions.SlowTestThresholdDefaultSeconds); + } +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Microsoft.Testing.Extensions.GitHubActionsReport.csproj b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Microsoft.Testing.Extensions.GitHubActionsReport.csproj +index 2f7a643746..80ae47d443 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Microsoft.Testing.Extensions.GitHubActionsReport.csproj ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Microsoft.Testing.Extensions.GitHubActionsReport.csproj +@@ -13,6 +13,7 @@ + + + ++ + + + +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/GitHubActionsResources.resx b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/GitHubActionsResources.resx +index 579a7f0f30..ec65d7cb29 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/GitHubActionsResources.resx ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/GitHubActionsResources.resx +@@ -62,8 +62,8 @@ + GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + + +- Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- {Locked="on"}{Locked="off"} ++ Enable or disable GitHub Actions failure annotations. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"}{Locked="true"}{Locked="enable"}{Locked="1"}{Locked="false"}{Locked="disable"}{Locked="0"} + + + Test failed: {0} +@@ -73,19 +73,20 @@ + The test failed without providing a failure message. + + +- Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- {Locked="on"}{Locked="off"}{Locked="GITHUB_STEP_SUMMARY"} ++ Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"}{Locked="true"}{Locked="enable"}{Locked="1"}{Locked="false"}{Locked="disable"}{Locked="0"}{Locked="GITHUB_STEP_SUMMARY"} + + + Failed to write the GitHub Actions job summary to '{0}': {1} + {0} is the file path, {1} is the error message. + + +- Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- {Locked="on"}{Locked="off"} ++ Enable or disable GitHub Actions slow-test notices. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"}{Locked="true"}{Locked="enable"}{Locked="1"}{Locked="false"}{Locked="disable"}{Locked="0"} + + +- The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. ++ The duration a test may run before a GitHub Actions slow-test notice is emitted. Accepts a bare number of seconds or a value with a unit suffix such as '90s', '2m', or '1.5h'. Defaults to 60s. ++ {Locked="s"}{Locked="m"}{Locked="h"} + + + Slow test: {0} +@@ -96,7 +97,8 @@ + {0} is the fully qualified test name, {1} is the elapsed seconds. + + +- Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. ++ Invalid value '{0}'. The slow-test threshold must be a positive duration, e.g. '60', '90s', or '2m'. ++ {Locked="s"}{Locked="m"} + + + GitHub Actions report generator +@@ -106,12 +108,12 @@ + {0} is the assembly name, {1} is the target framework moniker. + + +- Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- {Locked="on"}{Locked="off"} ++ Enable or disable per-assembly log groups. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"}{Locked="true"}{Locked="enable"}{Locked="1"}{Locked="false"}{Locked="disable"}{Locked="0"} + + +- Invalid value '{0}'. Valid values are 'on' and 'off'. +- {Locked="on"}{Locked="off"} ++ Invalid value '{0}'. Valid values are 'on' (or 'true', 'enable', '1') or 'off' (or 'false', 'disable', '0'). ++ {Locked="on"}{Locked="off"}{Locked="true"}{Locked="enable"}{Locked="1"}{Locked="false"}{Locked="disable"}{Locked="0"} + + + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.cs.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.cs.xlf +index be2fbed7c2..432611fc10 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.cs.xlf ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.cs.xlf +@@ -8,9 +8,9 @@ + {0} is the fully qualified test name. + + +- Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- {Locked="on"}{Locked="off"} ++ Enable or disable GitHub Actions failure annotations. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable GitHub Actions failure annotations. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"}{Locked="true"}{Locked="enable"}{Locked="1"}{Locked="false"}{Locked="disable"}{Locked="0"} + + + GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. +@@ -28,19 +28,19 @@ + {0} is the assembly name, {1} is the target framework moniker. + + +- Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- {Locked="on"}{Locked="off"} ++ Enable or disable per-assembly log groups. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable per-assembly log groups. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"}{Locked="true"}{Locked="enable"}{Locked="1"}{Locked="false"}{Locked="disable"}{Locked="0"} + + +- Invalid value '{0}'. Valid values are 'on' and 'off'. +- Invalid value '{0}'. Valid values are 'on' and 'off'. +- {Locked="on"}{Locked="off"} ++ Invalid value '{0}'. Valid values are 'on' (or 'true', 'enable', '1') or 'off' (or 'false', 'disable', '0'). ++ Invalid value '{0}'. Valid values are 'on' (or 'true', 'enable', '1') or 'off' (or 'false', 'disable', '0'). ++ {Locked="on"}{Locked="off"}{Locked="true"}{Locked="enable"}{Locked="1"}{Locked="false"}{Locked="disable"}{Locked="0"} + + +- Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. +- Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. +- ++ Invalid value '{0}'. The slow-test threshold must be a positive duration, e.g. '60', '90s', or '2m'. ++ Invalid value '{0}'. The slow-test threshold must be a positive duration, e.g. '60', '90s', or '2m'. ++ {Locked="s"}{Locked="m"} + + + The test failed without providing a failure message. +@@ -58,9 +58,9 @@ + {0} is the fully qualified test name. + + +- Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- {Locked="on"}{Locked="off"} ++ Enable or disable GitHub Actions slow-test notices. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable GitHub Actions slow-test notices. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"}{Locked="true"}{Locked="enable"}{Locked="1"}{Locked="false"}{Locked="disable"}{Locked="0"} + + + {0} still running after {1}s +@@ -68,14 +68,14 @@ + {0} is the fully qualified test name, {1} is the elapsed seconds. + + +- The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. +- The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. +- ++ The duration a test may run before a GitHub Actions slow-test notice is emitted. Accepts a bare number of seconds or a value with a unit suffix such as '90s', '2m', or '1.5h'. Defaults to 60s. ++ The duration a test may run before a GitHub Actions slow-test notice is emitted. Accepts a bare number of seconds or a value with a unit suffix such as '90s', '2m', or '1.5h'. Defaults to 60s. ++ {Locked="s"}{Locked="m"}{Locked="h"} + + +- Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- {Locked="on"}{Locked="off"}{Locked="GITHUB_STEP_SUMMARY"} ++ Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"}{Locked="true"}{Locked="enable"}{Locked="1"}{Locked="false"}{Locked="disable"}{Locked="0"}{Locked="GITHUB_STEP_SUMMARY"} + + + Failed to write the GitHub Actions job summary to '{0}': {1} +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.de.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.de.xlf +index af245b124f..e320897cc8 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.de.xlf ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.de.xlf +@@ -8,9 +8,9 @@ + {0} is the fully qualified test name. + + +- Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- {Locked="on"}{Locked="off"} ++ Enable or disable GitHub Actions failure annotations. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable GitHub Actions failure annotations. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"}{Locked="true"}{Locked="enable"}{Locked="1"}{Locked="false"}{Locked="disable"}{Locked="0"} + + + GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. +@@ -28,19 +28,19 @@ + {0} is the assembly name, {1} is the target framework moniker. + + +- Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- {Locked="on"}{Locked="off"} ++ Enable or disable per-assembly log groups. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable per-assembly log groups. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"}{Locked="true"}{Locked="enable"}{Locked="1"}{Locked="false"}{Locked="disable"}{Locked="0"} + + +- Invalid value '{0}'. Valid values are 'on' and 'off'. +- Invalid value '{0}'. Valid values are 'on' and 'off'. +- {Locked="on"}{Locked="off"} ++ Invalid value '{0}'. Valid values are 'on' (or 'true', 'enable', '1') or 'off' (or 'false', 'disable', '0'). ++ Invalid value '{0}'. Valid values are 'on' (or 'true', 'enable', '1') or 'off' (or 'false', 'disable', '0'). ++ {Locked="on"}{Locked="off"}{Locked="true"}{Locked="enable"}{Locked="1"}{Locked="false"}{Locked="disable"}{Locked="0"} + + +- Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. +- Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. +- ++ Invalid value '{0}'. The slow-test threshold must be a positive duration, e.g. '60', '90s', or '2m'. ++ Invalid value '{0}'. The slow-test threshold must be a positive duration, e.g. '60', '90s', or '2m'. ++ {Locked="s"}{Locked="m"} + + + The test failed without providing a failure message. +@@ -58,9 +58,9 @@ + {0} is the fully qualified test name. + + +- Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- {Locked="on"}{Locked="off"} ++ Enable or disable GitHub Actions slow-test notices. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable GitHub Actions slow-test notices. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"}{Locked="true"}{Locked="enable"}{Locked="1"}{Locked="false"}{Locked="disable"}{Locked="0"} + + + {0} still running after {1}s +@@ -68,14 +68,14 @@ + {0} is the fully qualified test name, {1} is the elapsed seconds. + + +- The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. +- The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. +- ++ The duration a test may run before a GitHub Actions slow-test notice is emitted. Accepts a bare number of seconds or a value with a unit suffix such as '90s', '2m', or '1.5h'. Defaults to 60s. ++ The duration a test may run before a GitHub Actions slow-test notice is emitted. Accepts a bare number of seconds or a value with a unit suffix such as '90s', '2m', or '1.5h'. Defaults to 60s. ++ {Locked="s"}{Locked="m"}{Locked="h"} + + +- Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- {Locked="on"}{Locked="off"}{Locked="GITHUB_STEP_SUMMARY"} ++ Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"}{Locked="true"}{Locked="enable"}{Locked="1"}{Locked="false"}{Locked="disable"}{Locked="0"}{Locked="GITHUB_STEP_SUMMARY"} + + + Failed to write the GitHub Actions job summary to '{0}': {1} +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.es.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.es.xlf +index c0ef507eb8..2b3c00a7b6 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.es.xlf ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.es.xlf +@@ -8,9 +8,9 @@ + {0} is the fully qualified test name. + + +- Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- {Locked="on"}{Locked="off"} ++ Enable or disable GitHub Actions failure annotations. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable GitHub Actions failure annotations. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"}{Locked="true"}{Locked="enable"}{Locked="1"}{Locked="false"}{Locked="disable"}{Locked="0"} + + + GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. +@@ -28,19 +28,19 @@ + {0} is the assembly name, {1} is the target framework moniker. + + +- Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- {Locked="on"}{Locked="off"} ++ Enable or disable per-assembly log groups. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable per-assembly log groups. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"}{Locked="true"}{Locked="enable"}{Locked="1"}{Locked="false"}{Locked="disable"}{Locked="0"} + + +- Invalid value '{0}'. Valid values are 'on' and 'off'. +- Invalid value '{0}'. Valid values are 'on' and 'off'. +- {Locked="on"}{Locked="off"} ++ Invalid value '{0}'. Valid values are 'on' (or 'true', 'enable', '1') or 'off' (or 'false', 'disable', '0'). ++ Invalid value '{0}'. Valid values are 'on' (or 'true', 'enable', '1') or 'off' (or 'false', 'disable', '0'). ++ {Locked="on"}{Locked="off"}{Locked="true"}{Locked="enable"}{Locked="1"}{Locked="false"}{Locked="disable"}{Locked="0"} + + +- Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. +- Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. +- ++ Invalid value '{0}'. The slow-test threshold must be a positive duration, e.g. '60', '90s', or '2m'. ++ Invalid value '{0}'. The slow-test threshold must be a positive duration, e.g. '60', '90s', or '2m'. ++ {Locked="s"}{Locked="m"} + + + The test failed without providing a failure message. +@@ -58,9 +58,9 @@ + {0} is the fully qualified test name. + + +- Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- {Locked="on"}{Locked="off"} ++ Enable or disable GitHub Actions slow-test notices. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable GitHub Actions slow-test notices. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"}{Locked="true"}{Locked="enable"}{Locked="1"}{Locked="false"}{Locked="disable"}{Locked="0"} + + + {0} still running after {1}s +@@ -68,14 +68,14 @@ + {0} is the fully qualified test name, {1} is the elapsed seconds. + + +- The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. +- The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. +- ++ The duration a test may run before a GitHub Actions slow-test notice is emitted. Accepts a bare number of seconds or a value with a unit suffix such as '90s', '2m', or '1.5h'. Defaults to 60s. ++ The duration a test may run before a GitHub Actions slow-test notice is emitted. Accepts a bare number of seconds or a value with a unit suffix such as '90s', '2m', or '1.5h'. Defaults to 60s. ++ {Locked="s"}{Locked="m"}{Locked="h"} + + +- Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- {Locked="on"}{Locked="off"}{Locked="GITHUB_STEP_SUMMARY"} ++ Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"}{Locked="true"}{Locked="enable"}{Locked="1"}{Locked="false"}{Locked="disable"}{Locked="0"}{Locked="GITHUB_STEP_SUMMARY"} + + + Failed to write the GitHub Actions job summary to '{0}': {1} +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.fr.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.fr.xlf +index 2dfbebbae0..dbf86ae3f1 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.fr.xlf ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.fr.xlf +@@ -8,9 +8,9 @@ + {0} is the fully qualified test name. + + +- Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- {Locked="on"}{Locked="off"} ++ Enable or disable GitHub Actions failure annotations. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable GitHub Actions failure annotations. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"}{Locked="true"}{Locked="enable"}{Locked="1"}{Locked="false"}{Locked="disable"}{Locked="0"} + + + GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. +@@ -28,19 +28,19 @@ + {0} is the assembly name, {1} is the target framework moniker. + + +- Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- {Locked="on"}{Locked="off"} ++ Enable or disable per-assembly log groups. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable per-assembly log groups. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"}{Locked="true"}{Locked="enable"}{Locked="1"}{Locked="false"}{Locked="disable"}{Locked="0"} + + +- Invalid value '{0}'. Valid values are 'on' and 'off'. +- Invalid value '{0}'. Valid values are 'on' and 'off'. +- {Locked="on"}{Locked="off"} ++ Invalid value '{0}'. Valid values are 'on' (or 'true', 'enable', '1') or 'off' (or 'false', 'disable', '0'). ++ Invalid value '{0}'. Valid values are 'on' (or 'true', 'enable', '1') or 'off' (or 'false', 'disable', '0'). ++ {Locked="on"}{Locked="off"}{Locked="true"}{Locked="enable"}{Locked="1"}{Locked="false"}{Locked="disable"}{Locked="0"} + + +- Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. +- Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. +- ++ Invalid value '{0}'. The slow-test threshold must be a positive duration, e.g. '60', '90s', or '2m'. ++ Invalid value '{0}'. The slow-test threshold must be a positive duration, e.g. '60', '90s', or '2m'. ++ {Locked="s"}{Locked="m"} + + + The test failed without providing a failure message. +@@ -58,9 +58,9 @@ + {0} is the fully qualified test name. + + +- Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- {Locked="on"}{Locked="off"} ++ Enable or disable GitHub Actions slow-test notices. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable GitHub Actions slow-test notices. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"}{Locked="true"}{Locked="enable"}{Locked="1"}{Locked="false"}{Locked="disable"}{Locked="0"} + + + {0} still running after {1}s +@@ -68,14 +68,14 @@ + {0} is the fully qualified test name, {1} is the elapsed seconds. + + +- The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. +- The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. +- ++ The duration a test may run before a GitHub Actions slow-test notice is emitted. Accepts a bare number of seconds or a value with a unit suffix such as '90s', '2m', or '1.5h'. Defaults to 60s. ++ The duration a test may run before a GitHub Actions slow-test notice is emitted. Accepts a bare number of seconds or a value with a unit suffix such as '90s', '2m', or '1.5h'. Defaults to 60s. ++ {Locked="s"}{Locked="m"}{Locked="h"} + + +- Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- {Locked="on"}{Locked="off"}{Locked="GITHUB_STEP_SUMMARY"} ++ Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"}{Locked="true"}{Locked="enable"}{Locked="1"}{Locked="false"}{Locked="disable"}{Locked="0"}{Locked="GITHUB_STEP_SUMMARY"} + + + Failed to write the GitHub Actions job summary to '{0}': {1} +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.it.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.it.xlf +index 5820a68215..35d93329b2 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.it.xlf ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.it.xlf +@@ -8,9 +8,9 @@ + {0} is the fully qualified test name. + + +- Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- {Locked="on"}{Locked="off"} ++ Enable or disable GitHub Actions failure annotations. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable GitHub Actions failure annotations. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"}{Locked="true"}{Locked="enable"}{Locked="1"}{Locked="false"}{Locked="disable"}{Locked="0"} + + + GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. +@@ -28,19 +28,19 @@ + {0} is the assembly name, {1} is the target framework moniker. + + +- Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- {Locked="on"}{Locked="off"} ++ Enable or disable per-assembly log groups. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable per-assembly log groups. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"}{Locked="true"}{Locked="enable"}{Locked="1"}{Locked="false"}{Locked="disable"}{Locked="0"} + + +- Invalid value '{0}'. Valid values are 'on' and 'off'. +- Invalid value '{0}'. Valid values are 'on' and 'off'. +- {Locked="on"}{Locked="off"} ++ Invalid value '{0}'. Valid values are 'on' (or 'true', 'enable', '1') or 'off' (or 'false', 'disable', '0'). ++ Invalid value '{0}'. Valid values are 'on' (or 'true', 'enable', '1') or 'off' (or 'false', 'disable', '0'). ++ {Locked="on"}{Locked="off"}{Locked="true"}{Locked="enable"}{Locked="1"}{Locked="false"}{Locked="disable"}{Locked="0"} + + +- Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. +- Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. +- ++ Invalid value '{0}'. The slow-test threshold must be a positive duration, e.g. '60', '90s', or '2m'. ++ Invalid value '{0}'. The slow-test threshold must be a positive duration, e.g. '60', '90s', or '2m'. ++ {Locked="s"}{Locked="m"} + + + The test failed without providing a failure message. +@@ -58,9 +58,9 @@ + {0} is the fully qualified test name. + + +- Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- {Locked="on"}{Locked="off"} ++ Enable or disable GitHub Actions slow-test notices. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable GitHub Actions slow-test notices. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"}{Locked="true"}{Locked="enable"}{Locked="1"}{Locked="false"}{Locked="disable"}{Locked="0"} + + + {0} still running after {1}s +@@ -68,14 +68,14 @@ + {0} is the fully qualified test name, {1} is the elapsed seconds. + + +- The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. +- The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. +- ++ The duration a test may run before a GitHub Actions slow-test notice is emitted. Accepts a bare number of seconds or a value with a unit suffix such as '90s', '2m', or '1.5h'. Defaults to 60s. ++ The duration a test may run before a GitHub Actions slow-test notice is emitted. Accepts a bare number of seconds or a value with a unit suffix such as '90s', '2m', or '1.5h'. Defaults to 60s. ++ {Locked="s"}{Locked="m"}{Locked="h"} + + +- Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- {Locked="on"}{Locked="off"}{Locked="GITHUB_STEP_SUMMARY"} ++ Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"}{Locked="true"}{Locked="enable"}{Locked="1"}{Locked="false"}{Locked="disable"}{Locked="0"}{Locked="GITHUB_STEP_SUMMARY"} + + + Failed to write the GitHub Actions job summary to '{0}': {1} +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ja.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ja.xlf +index f57965206a..7e70cb7cb2 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ja.xlf ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ja.xlf +@@ -8,9 +8,9 @@ + {0} is the fully qualified test name. + + +- Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- {Locked="on"}{Locked="off"} ++ Enable or disable GitHub Actions failure annotations. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable GitHub Actions failure annotations. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"}{Locked="true"}{Locked="enable"}{Locked="1"}{Locked="false"}{Locked="disable"}{Locked="0"} + + + GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. +@@ -28,19 +28,19 @@ + {0} is the assembly name, {1} is the target framework moniker. + + +- Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- {Locked="on"}{Locked="off"} ++ Enable or disable per-assembly log groups. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable per-assembly log groups. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"}{Locked="true"}{Locked="enable"}{Locked="1"}{Locked="false"}{Locked="disable"}{Locked="0"} + + +- Invalid value '{0}'. Valid values are 'on' and 'off'. +- Invalid value '{0}'. Valid values are 'on' and 'off'. +- {Locked="on"}{Locked="off"} ++ Invalid value '{0}'. Valid values are 'on' (or 'true', 'enable', '1') or 'off' (or 'false', 'disable', '0'). ++ Invalid value '{0}'. Valid values are 'on' (or 'true', 'enable', '1') or 'off' (or 'false', 'disable', '0'). ++ {Locked="on"}{Locked="off"}{Locked="true"}{Locked="enable"}{Locked="1"}{Locked="false"}{Locked="disable"}{Locked="0"} + + +- Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. +- Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. +- ++ Invalid value '{0}'. The slow-test threshold must be a positive duration, e.g. '60', '90s', or '2m'. ++ Invalid value '{0}'. The slow-test threshold must be a positive duration, e.g. '60', '90s', or '2m'. ++ {Locked="s"}{Locked="m"} + + + The test failed without providing a failure message. +@@ -58,9 +58,9 @@ + {0} is the fully qualified test name. + + +- Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- {Locked="on"}{Locked="off"} ++ Enable or disable GitHub Actions slow-test notices. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable GitHub Actions slow-test notices. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"}{Locked="true"}{Locked="enable"}{Locked="1"}{Locked="false"}{Locked="disable"}{Locked="0"} + + + {0} still running after {1}s +@@ -68,14 +68,14 @@ + {0} is the fully qualified test name, {1} is the elapsed seconds. + + +- The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. +- The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. +- ++ The duration a test may run before a GitHub Actions slow-test notice is emitted. Accepts a bare number of seconds or a value with a unit suffix such as '90s', '2m', or '1.5h'. Defaults to 60s. ++ The duration a test may run before a GitHub Actions slow-test notice is emitted. Accepts a bare number of seconds or a value with a unit suffix such as '90s', '2m', or '1.5h'. Defaults to 60s. ++ {Locked="s"}{Locked="m"}{Locked="h"} + + +- Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- {Locked="on"}{Locked="off"}{Locked="GITHUB_STEP_SUMMARY"} ++ Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"}{Locked="true"}{Locked="enable"}{Locked="1"}{Locked="false"}{Locked="disable"}{Locked="0"}{Locked="GITHUB_STEP_SUMMARY"} + + + Failed to write the GitHub Actions job summary to '{0}': {1} +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ko.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ko.xlf +index 2cdaa3623f..c696cb06d0 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ko.xlf ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ko.xlf +@@ -8,9 +8,9 @@ + {0} is the fully qualified test name. + + +- Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- {Locked="on"}{Locked="off"} ++ Enable or disable GitHub Actions failure annotations. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable GitHub Actions failure annotations. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"}{Locked="true"}{Locked="enable"}{Locked="1"}{Locked="false"}{Locked="disable"}{Locked="0"} + + + GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. +@@ -28,19 +28,19 @@ + {0} is the assembly name, {1} is the target framework moniker. + + +- Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- {Locked="on"}{Locked="off"} ++ Enable or disable per-assembly log groups. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable per-assembly log groups. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"}{Locked="true"}{Locked="enable"}{Locked="1"}{Locked="false"}{Locked="disable"}{Locked="0"} + + +- Invalid value '{0}'. Valid values are 'on' and 'off'. +- Invalid value '{0}'. Valid values are 'on' and 'off'. +- {Locked="on"}{Locked="off"} ++ Invalid value '{0}'. Valid values are 'on' (or 'true', 'enable', '1') or 'off' (or 'false', 'disable', '0'). ++ Invalid value '{0}'. Valid values are 'on' (or 'true', 'enable', '1') or 'off' (or 'false', 'disable', '0'). ++ {Locked="on"}{Locked="off"}{Locked="true"}{Locked="enable"}{Locked="1"}{Locked="false"}{Locked="disable"}{Locked="0"} + + +- Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. +- Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. +- ++ Invalid value '{0}'. The slow-test threshold must be a positive duration, e.g. '60', '90s', or '2m'. ++ Invalid value '{0}'. The slow-test threshold must be a positive duration, e.g. '60', '90s', or '2m'. ++ {Locked="s"}{Locked="m"} + + + The test failed without providing a failure message. +@@ -58,9 +58,9 @@ + {0} is the fully qualified test name. + + +- Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- {Locked="on"}{Locked="off"} ++ Enable or disable GitHub Actions slow-test notices. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable GitHub Actions slow-test notices. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"}{Locked="true"}{Locked="enable"}{Locked="1"}{Locked="false"}{Locked="disable"}{Locked="0"} + + + {0} still running after {1}s +@@ -68,14 +68,14 @@ + {0} is the fully qualified test name, {1} is the elapsed seconds. + + +- The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. +- The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. +- ++ The duration a test may run before a GitHub Actions slow-test notice is emitted. Accepts a bare number of seconds or a value with a unit suffix such as '90s', '2m', or '1.5h'. Defaults to 60s. ++ The duration a test may run before a GitHub Actions slow-test notice is emitted. Accepts a bare number of seconds or a value with a unit suffix such as '90s', '2m', or '1.5h'. Defaults to 60s. ++ {Locked="s"}{Locked="m"}{Locked="h"} + + +- Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- {Locked="on"}{Locked="off"}{Locked="GITHUB_STEP_SUMMARY"} ++ Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"}{Locked="true"}{Locked="enable"}{Locked="1"}{Locked="false"}{Locked="disable"}{Locked="0"}{Locked="GITHUB_STEP_SUMMARY"} + + + Failed to write the GitHub Actions job summary to '{0}': {1} +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.pl.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.pl.xlf +index 7e4e3c639b..0d44ee2503 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.pl.xlf ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.pl.xlf +@@ -8,9 +8,9 @@ + {0} is the fully qualified test name. + + +- Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- {Locked="on"}{Locked="off"} ++ Enable or disable GitHub Actions failure annotations. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable GitHub Actions failure annotations. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"}{Locked="true"}{Locked="enable"}{Locked="1"}{Locked="false"}{Locked="disable"}{Locked="0"} + + + GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. +@@ -28,19 +28,19 @@ + {0} is the assembly name, {1} is the target framework moniker. + + +- Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- {Locked="on"}{Locked="off"} ++ Enable or disable per-assembly log groups. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable per-assembly log groups. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"}{Locked="true"}{Locked="enable"}{Locked="1"}{Locked="false"}{Locked="disable"}{Locked="0"} + + +- Invalid value '{0}'. Valid values are 'on' and 'off'. +- Invalid value '{0}'. Valid values are 'on' and 'off'. +- {Locked="on"}{Locked="off"} ++ Invalid value '{0}'. Valid values are 'on' (or 'true', 'enable', '1') or 'off' (or 'false', 'disable', '0'). ++ Invalid value '{0}'. Valid values are 'on' (or 'true', 'enable', '1') or 'off' (or 'false', 'disable', '0'). ++ {Locked="on"}{Locked="off"}{Locked="true"}{Locked="enable"}{Locked="1"}{Locked="false"}{Locked="disable"}{Locked="0"} + + +- Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. +- Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. +- ++ Invalid value '{0}'. The slow-test threshold must be a positive duration, e.g. '60', '90s', or '2m'. ++ Invalid value '{0}'. The slow-test threshold must be a positive duration, e.g. '60', '90s', or '2m'. ++ {Locked="s"}{Locked="m"} + + + The test failed without providing a failure message. +@@ -58,9 +58,9 @@ + {0} is the fully qualified test name. + + +- Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- {Locked="on"}{Locked="off"} ++ Enable or disable GitHub Actions slow-test notices. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable GitHub Actions slow-test notices. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"}{Locked="true"}{Locked="enable"}{Locked="1"}{Locked="false"}{Locked="disable"}{Locked="0"} + + + {0} still running after {1}s +@@ -68,14 +68,14 @@ + {0} is the fully qualified test name, {1} is the elapsed seconds. + + +- The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. +- The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. +- ++ The duration a test may run before a GitHub Actions slow-test notice is emitted. Accepts a bare number of seconds or a value with a unit suffix such as '90s', '2m', or '1.5h'. Defaults to 60s. ++ The duration a test may run before a GitHub Actions slow-test notice is emitted. Accepts a bare number of seconds or a value with a unit suffix such as '90s', '2m', or '1.5h'. Defaults to 60s. ++ {Locked="s"}{Locked="m"}{Locked="h"} + + +- Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- {Locked="on"}{Locked="off"}{Locked="GITHUB_STEP_SUMMARY"} ++ Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"}{Locked="true"}{Locked="enable"}{Locked="1"}{Locked="false"}{Locked="disable"}{Locked="0"}{Locked="GITHUB_STEP_SUMMARY"} + + + Failed to write the GitHub Actions job summary to '{0}': {1} +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.pt-BR.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.pt-BR.xlf +index 45497c6b59..5f1dab9dda 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.pt-BR.xlf ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.pt-BR.xlf +@@ -8,9 +8,9 @@ + {0} is the fully qualified test name. + + +- Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- {Locked="on"}{Locked="off"} ++ Enable or disable GitHub Actions failure annotations. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable GitHub Actions failure annotations. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"}{Locked="true"}{Locked="enable"}{Locked="1"}{Locked="false"}{Locked="disable"}{Locked="0"} + + + GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. +@@ -28,19 +28,19 @@ + {0} is the assembly name, {1} is the target framework moniker. + + +- Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- {Locked="on"}{Locked="off"} ++ Enable or disable per-assembly log groups. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable per-assembly log groups. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"}{Locked="true"}{Locked="enable"}{Locked="1"}{Locked="false"}{Locked="disable"}{Locked="0"} + + +- Invalid value '{0}'. Valid values are 'on' and 'off'. +- Invalid value '{0}'. Valid values are 'on' and 'off'. +- {Locked="on"}{Locked="off"} ++ Invalid value '{0}'. Valid values are 'on' (or 'true', 'enable', '1') or 'off' (or 'false', 'disable', '0'). ++ Invalid value '{0}'. Valid values are 'on' (or 'true', 'enable', '1') or 'off' (or 'false', 'disable', '0'). ++ {Locked="on"}{Locked="off"}{Locked="true"}{Locked="enable"}{Locked="1"}{Locked="false"}{Locked="disable"}{Locked="0"} + + +- Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. +- Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. +- ++ Invalid value '{0}'. The slow-test threshold must be a positive duration, e.g. '60', '90s', or '2m'. ++ Invalid value '{0}'. The slow-test threshold must be a positive duration, e.g. '60', '90s', or '2m'. ++ {Locked="s"}{Locked="m"} + + + The test failed without providing a failure message. +@@ -58,9 +58,9 @@ + {0} is the fully qualified test name. + + +- Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- {Locked="on"}{Locked="off"} ++ Enable or disable GitHub Actions slow-test notices. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable GitHub Actions slow-test notices. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"}{Locked="true"}{Locked="enable"}{Locked="1"}{Locked="false"}{Locked="disable"}{Locked="0"} + + + {0} still running after {1}s +@@ -68,14 +68,14 @@ + {0} is the fully qualified test name, {1} is the elapsed seconds. + + +- The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. +- The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. +- ++ The duration a test may run before a GitHub Actions slow-test notice is emitted. Accepts a bare number of seconds or a value with a unit suffix such as '90s', '2m', or '1.5h'. Defaults to 60s. ++ The duration a test may run before a GitHub Actions slow-test notice is emitted. Accepts a bare number of seconds or a value with a unit suffix such as '90s', '2m', or '1.5h'. Defaults to 60s. ++ {Locked="s"}{Locked="m"}{Locked="h"} + + +- Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- {Locked="on"}{Locked="off"}{Locked="GITHUB_STEP_SUMMARY"} ++ Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"}{Locked="true"}{Locked="enable"}{Locked="1"}{Locked="false"}{Locked="disable"}{Locked="0"}{Locked="GITHUB_STEP_SUMMARY"} + + + Failed to write the GitHub Actions job summary to '{0}': {1} +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ru.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ru.xlf +index e40f505e56..f3b69bb488 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ru.xlf ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ru.xlf +@@ -8,9 +8,9 @@ + {0} is the fully qualified test name. + + +- Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- {Locked="on"}{Locked="off"} ++ Enable or disable GitHub Actions failure annotations. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable GitHub Actions failure annotations. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"}{Locked="true"}{Locked="enable"}{Locked="1"}{Locked="false"}{Locked="disable"}{Locked="0"} + + + GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. +@@ -28,19 +28,19 @@ + {0} is the assembly name, {1} is the target framework moniker. + + +- Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- {Locked="on"}{Locked="off"} ++ Enable or disable per-assembly log groups. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable per-assembly log groups. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"}{Locked="true"}{Locked="enable"}{Locked="1"}{Locked="false"}{Locked="disable"}{Locked="0"} + + +- Invalid value '{0}'. Valid values are 'on' and 'off'. +- Invalid value '{0}'. Valid values are 'on' and 'off'. +- {Locked="on"}{Locked="off"} ++ Invalid value '{0}'. Valid values are 'on' (or 'true', 'enable', '1') or 'off' (or 'false', 'disable', '0'). ++ Invalid value '{0}'. Valid values are 'on' (or 'true', 'enable', '1') or 'off' (or 'false', 'disable', '0'). ++ {Locked="on"}{Locked="off"}{Locked="true"}{Locked="enable"}{Locked="1"}{Locked="false"}{Locked="disable"}{Locked="0"} + + +- Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. +- Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. +- ++ Invalid value '{0}'. The slow-test threshold must be a positive duration, e.g. '60', '90s', or '2m'. ++ Invalid value '{0}'. The slow-test threshold must be a positive duration, e.g. '60', '90s', or '2m'. ++ {Locked="s"}{Locked="m"} + + + The test failed without providing a failure message. +@@ -58,9 +58,9 @@ + {0} is the fully qualified test name. + + +- Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- {Locked="on"}{Locked="off"} ++ Enable or disable GitHub Actions slow-test notices. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable GitHub Actions slow-test notices. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"}{Locked="true"}{Locked="enable"}{Locked="1"}{Locked="false"}{Locked="disable"}{Locked="0"} + + + {0} still running after {1}s +@@ -68,14 +68,14 @@ + {0} is the fully qualified test name, {1} is the elapsed seconds. + + +- The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. +- The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. +- ++ The duration a test may run before a GitHub Actions slow-test notice is emitted. Accepts a bare number of seconds or a value with a unit suffix such as '90s', '2m', or '1.5h'. Defaults to 60s. ++ The duration a test may run before a GitHub Actions slow-test notice is emitted. Accepts a bare number of seconds or a value with a unit suffix such as '90s', '2m', or '1.5h'. Defaults to 60s. ++ {Locked="s"}{Locked="m"}{Locked="h"} + + +- Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- {Locked="on"}{Locked="off"}{Locked="GITHUB_STEP_SUMMARY"} ++ Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"}{Locked="true"}{Locked="enable"}{Locked="1"}{Locked="false"}{Locked="disable"}{Locked="0"}{Locked="GITHUB_STEP_SUMMARY"} + + + Failed to write the GitHub Actions job summary to '{0}': {1} +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.tr.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.tr.xlf +index 9e0630d207..83525819c6 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.tr.xlf ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.tr.xlf +@@ -8,9 +8,9 @@ + {0} is the fully qualified test name. + + +- Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- {Locked="on"}{Locked="off"} ++ Enable or disable GitHub Actions failure annotations. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable GitHub Actions failure annotations. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"}{Locked="true"}{Locked="enable"}{Locked="1"}{Locked="false"}{Locked="disable"}{Locked="0"} + + + GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. +@@ -28,19 +28,19 @@ + {0} is the assembly name, {1} is the target framework moniker. + + +- Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- {Locked="on"}{Locked="off"} ++ Enable or disable per-assembly log groups. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable per-assembly log groups. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"}{Locked="true"}{Locked="enable"}{Locked="1"}{Locked="false"}{Locked="disable"}{Locked="0"} + + +- Invalid value '{0}'. Valid values are 'on' and 'off'. +- Invalid value '{0}'. Valid values are 'on' and 'off'. +- {Locked="on"}{Locked="off"} ++ Invalid value '{0}'. Valid values are 'on' (or 'true', 'enable', '1') or 'off' (or 'false', 'disable', '0'). ++ Invalid value '{0}'. Valid values are 'on' (or 'true', 'enable', '1') or 'off' (or 'false', 'disable', '0'). ++ {Locked="on"}{Locked="off"}{Locked="true"}{Locked="enable"}{Locked="1"}{Locked="false"}{Locked="disable"}{Locked="0"} + + +- Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. +- Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. +- ++ Invalid value '{0}'. The slow-test threshold must be a positive duration, e.g. '60', '90s', or '2m'. ++ Invalid value '{0}'. The slow-test threshold must be a positive duration, e.g. '60', '90s', or '2m'. ++ {Locked="s"}{Locked="m"} + + + The test failed without providing a failure message. +@@ -58,9 +58,9 @@ + {0} is the fully qualified test name. + + +- Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- {Locked="on"}{Locked="off"} ++ Enable or disable GitHub Actions slow-test notices. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable GitHub Actions slow-test notices. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"}{Locked="true"}{Locked="enable"}{Locked="1"}{Locked="false"}{Locked="disable"}{Locked="0"} + + + {0} still running after {1}s +@@ -68,14 +68,14 @@ + {0} is the fully qualified test name, {1} is the elapsed seconds. + + +- The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. +- The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. +- ++ The duration a test may run before a GitHub Actions slow-test notice is emitted. Accepts a bare number of seconds or a value with a unit suffix such as '90s', '2m', or '1.5h'. Defaults to 60s. ++ The duration a test may run before a GitHub Actions slow-test notice is emitted. Accepts a bare number of seconds or a value with a unit suffix such as '90s', '2m', or '1.5h'. Defaults to 60s. ++ {Locked="s"}{Locked="m"}{Locked="h"} + + +- Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- {Locked="on"}{Locked="off"}{Locked="GITHUB_STEP_SUMMARY"} ++ Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"}{Locked="true"}{Locked="enable"}{Locked="1"}{Locked="false"}{Locked="disable"}{Locked="0"}{Locked="GITHUB_STEP_SUMMARY"} + + + Failed to write the GitHub Actions job summary to '{0}': {1} +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.zh-Hans.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.zh-Hans.xlf +index e1156de0ef..395751aa81 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.zh-Hans.xlf ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.zh-Hans.xlf +@@ -8,9 +8,9 @@ + {0} is the fully qualified test name. + + +- Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- {Locked="on"}{Locked="off"} ++ Enable or disable GitHub Actions failure annotations. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable GitHub Actions failure annotations. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"}{Locked="true"}{Locked="enable"}{Locked="1"}{Locked="false"}{Locked="disable"}{Locked="0"} + + + GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. +@@ -28,19 +28,19 @@ + {0} is the assembly name, {1} is the target framework moniker. + + +- Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- {Locked="on"}{Locked="off"} ++ Enable or disable per-assembly log groups. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable per-assembly log groups. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"}{Locked="true"}{Locked="enable"}{Locked="1"}{Locked="false"}{Locked="disable"}{Locked="0"} + + +- Invalid value '{0}'. Valid values are 'on' and 'off'. +- Invalid value '{0}'. Valid values are 'on' and 'off'. +- {Locked="on"}{Locked="off"} ++ Invalid value '{0}'. Valid values are 'on' (or 'true', 'enable', '1') or 'off' (or 'false', 'disable', '0'). ++ Invalid value '{0}'. Valid values are 'on' (or 'true', 'enable', '1') or 'off' (or 'false', 'disable', '0'). ++ {Locked="on"}{Locked="off"}{Locked="true"}{Locked="enable"}{Locked="1"}{Locked="false"}{Locked="disable"}{Locked="0"} + + +- Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. +- Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. +- ++ Invalid value '{0}'. The slow-test threshold must be a positive duration, e.g. '60', '90s', or '2m'. ++ Invalid value '{0}'. The slow-test threshold must be a positive duration, e.g. '60', '90s', or '2m'. ++ {Locked="s"}{Locked="m"} + + + The test failed without providing a failure message. +@@ -58,9 +58,9 @@ + {0} is the fully qualified test name. + + +- Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- {Locked="on"}{Locked="off"} ++ Enable or disable GitHub Actions slow-test notices. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable GitHub Actions slow-test notices. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"}{Locked="true"}{Locked="enable"}{Locked="1"}{Locked="false"}{Locked="disable"}{Locked="0"} + + + {0} still running after {1}s +@@ -68,14 +68,14 @@ + {0} is the fully qualified test name, {1} is the elapsed seconds. + + +- The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. +- The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. +- ++ The duration a test may run before a GitHub Actions slow-test notice is emitted. Accepts a bare number of seconds or a value with a unit suffix such as '90s', '2m', or '1.5h'. Defaults to 60s. ++ The duration a test may run before a GitHub Actions slow-test notice is emitted. Accepts a bare number of seconds or a value with a unit suffix such as '90s', '2m', or '1.5h'. Defaults to 60s. ++ {Locked="s"}{Locked="m"}{Locked="h"} + + +- Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- {Locked="on"}{Locked="off"}{Locked="GITHUB_STEP_SUMMARY"} ++ Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"}{Locked="true"}{Locked="enable"}{Locked="1"}{Locked="false"}{Locked="disable"}{Locked="0"}{Locked="GITHUB_STEP_SUMMARY"} + + + Failed to write the GitHub Actions job summary to '{0}': {1} +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.zh-Hant.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.zh-Hant.xlf +index 7f7a144bb7..257c67e094 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.zh-Hant.xlf ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.zh-Hant.xlf +@@ -8,9 +8,9 @@ + {0} is the fully qualified test name. + + +- Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- {Locked="on"}{Locked="off"} ++ Enable or disable GitHub Actions failure annotations. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable GitHub Actions failure annotations. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"}{Locked="true"}{Locked="enable"}{Locked="1"}{Locked="false"}{Locked="disable"}{Locked="0"} + + + GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. +@@ -28,19 +28,19 @@ + {0} is the assembly name, {1} is the target framework moniker. + + +- Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- {Locked="on"}{Locked="off"} ++ Enable or disable per-assembly log groups. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable per-assembly log groups. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"}{Locked="true"}{Locked="enable"}{Locked="1"}{Locked="false"}{Locked="disable"}{Locked="0"} + + +- Invalid value '{0}'. Valid values are 'on' and 'off'. +- Invalid value '{0}'. Valid values are 'on' and 'off'. +- {Locked="on"}{Locked="off"} ++ Invalid value '{0}'. Valid values are 'on' (or 'true', 'enable', '1') or 'off' (or 'false', 'disable', '0'). ++ Invalid value '{0}'. Valid values are 'on' (or 'true', 'enable', '1') or 'off' (or 'false', 'disable', '0'). ++ {Locked="on"}{Locked="off"}{Locked="true"}{Locked="enable"}{Locked="1"}{Locked="false"}{Locked="disable"}{Locked="0"} + + +- Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. +- Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. +- ++ Invalid value '{0}'. The slow-test threshold must be a positive duration, e.g. '60', '90s', or '2m'. ++ Invalid value '{0}'. The slow-test threshold must be a positive duration, e.g. '60', '90s', or '2m'. ++ {Locked="s"}{Locked="m"} + + + The test failed without providing a failure message. +@@ -58,9 +58,9 @@ + {0} is the fully qualified test name. + + +- Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- {Locked="on"}{Locked="off"} ++ Enable or disable GitHub Actions slow-test notices. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable GitHub Actions slow-test notices. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"}{Locked="true"}{Locked="enable"}{Locked="1"}{Locked="false"}{Locked="disable"}{Locked="0"} + + + {0} still running after {1}s +@@ -68,14 +68,14 @@ + {0} is the fully qualified test name, {1} is the elapsed seconds. + + +- The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. +- The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. +- ++ The duration a test may run before a GitHub Actions slow-test notice is emitted. Accepts a bare number of seconds or a value with a unit suffix such as '90s', '2m', or '1.5h'. Defaults to 60s. ++ The duration a test may run before a GitHub Actions slow-test notice is emitted. Accepts a bare number of seconds or a value with a unit suffix such as '90s', '2m', or '1.5h'. Defaults to 60s. ++ {Locked="s"}{Locked="m"}{Locked="h"} + + +- Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. +- {Locked="on"}{Locked="off"}{Locked="GITHUB_STEP_SUMMARY"} ++ Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. ++ {Locked="on"}{Locked="off"}{Locked="true"}{Locked="enable"}{Locked="1"}{Locked="false"}{Locked="disable"}{Locked="0"}{Locked="GITHUB_STEP_SUMMARY"} + + + Failed to write the GitHub Actions job summary to '{0}': {1} +diff --git a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/HelpInfoAllExtensionsTests.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/HelpInfoAllExtensionsTests.cs +index 5683f07ee8..f28ac9cdd0 100644 +--- a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/HelpInfoAllExtensionsTests.cs ++++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/HelpInfoAllExtensionsTests.cs +@@ -184,15 +184,15 @@ Enable generating a CTRF (Common Test Report Format) JSON report + --report-gh + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + --report-gh-annotations +- Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable GitHub Actions failure annotations. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. + --report-gh-groups +- Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable per-assembly log groups. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. + --report-gh-slow-test-notices +- Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable GitHub Actions slow-test notices. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. + --report-gh-slow-test-threshold +- The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. ++ The duration a test may run before a GitHub Actions slow-test notice is emitted. Accepts a bare number of seconds or a value with a unit suffix such as '90s', '2m', or '1.5h'. Defaults to 60s. + --report-gh-step-summary +- Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. + --report-html + Enable generating an HTML report + --report-html-filename +@@ -558,23 +558,23 @@ The file makes it possible to identify the tests that were running at the time o + --report-gh-annotations + Arity: 1 + Hidden: False +- Description: Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ Description: Enable or disable GitHub Actions failure annotations. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. + --report-gh-groups + Arity: 1 + Hidden: False +- Description: Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ Description: Enable or disable per-assembly log groups. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. + --report-gh-slow-test-notices + Arity: 1 + Hidden: False +- Description: Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ Description: Enable or disable GitHub Actions slow-test notices. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. + --report-gh-slow-test-threshold + Arity: 1 + Hidden: False +- Description: The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. ++ Description: The duration a test may run before a GitHub Actions slow-test notice is emitted. Accepts a bare number of seconds or a value with a unit suffix such as '90s', '2m', or '1.5h'. Defaults to 60s. + --report-gh-step-summary + Arity: 1 + Hidden: False +- Description: Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. ++ Description: Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file. Valid values are 'on' (also accepts 'true', 'enable', '1') or 'off' (also accepts 'false', 'disable', '0'). Defaults to 'on' when running on GitHub Actions. + HangDumpCommandLineProvider + Name: Hang dump + Version: * +diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsCommandLineProviderTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsCommandLineProviderTests.cs +index a4ecda8af7..c77e51922c 100644 +--- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsCommandLineProviderTests.cs ++++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsCommandLineProviderTests.cs +@@ -92,4 +92,47 @@ public async Task ValidateOptionArgumentsAsync_ReturnsValid_WhenSlowTestThreshol + + Assert.IsTrue(validationResult.IsValid); + } ++ ++ [TestMethod] ++ [DataRow("true")] ++ [DataRow("enable")] ++ [DataRow("1")] ++ [DataRow("false")] ++ [DataRow("disable")] ++ [DataRow("0")] ++ public async Task ValidateOptionArgumentsAsync_ReturnsValid_ForBooleanAliasesAsync(string value) ++ { ++ GitHubActionsCommandLineProvider provider = new(); ++ CommandLineOption option = provider.GetCommandLineOptions().Single(o => o.Name == GitHubActionsCommandLineOptions.GitHubActionsGroups); ++ ValidationResult validationResult = await provider.ValidateOptionArgumentsAsync(option, [value]).ConfigureAwait(false); ++ ++ Assert.IsTrue(validationResult.IsValid, value); ++ } ++ ++ [TestMethod] ++ [DataRow("90s")] ++ [DataRow("2m")] ++ [DataRow("1.5h")] ++ [DataRow("60")] ++ public async Task ValidateOptionArgumentsAsync_ReturnsValid_ForSlowTestThresholdDurationsAsync(string value) ++ { ++ GitHubActionsCommandLineProvider provider = new(); ++ CommandLineOption option = provider.GetCommandLineOptions().Single(o => o.Name == GitHubActionsCommandLineOptions.GitHubActionsSlowTestThreshold); ++ ValidationResult validationResult = await provider.ValidateOptionArgumentsAsync(option, [value]).ConfigureAwait(false); ++ ++ Assert.IsTrue(validationResult.IsValid, value); ++ } ++ ++ [TestMethod] ++ [DataRow("0s")] ++ [DataRow("abc")] ++ [DataRow("-5s")] ++ public async Task ValidateOptionArgumentsAsync_ReturnsInvalid_ForNonPositiveOrUnparseableThresholdAsync(string value) ++ { ++ GitHubActionsCommandLineProvider provider = new(); ++ CommandLineOption option = provider.GetCommandLineOptions().Single(o => o.Name == GitHubActionsCommandLineOptions.GitHubActionsSlowTestThreshold); ++ ValidationResult validationResult = await provider.ValidateOptionArgumentsAsync(option, [value]).ConfigureAwait(false); ++ ++ Assert.IsFalse(validationResult.IsValid, value); ++ } + } + +From 3e3809307563899e263cf6824ffaf6cacdc702df Mon Sep 17 00:00:00 2001 +From: Copilot App <223556219+Copilot@users.noreply.github.com> +Date: Fri, 3 Jul 2026 14:00:40 +0200 +Subject: [PATCH 30/32] Dedup stack-trace source-location resolution into a + shared helper + +- Extract the stack-frame source-location resolver (frame parsing, + deterministic-build-root handling, MSTest assertion-frame skipping, repo + relativization) into SharedExtensionHelpers/StackTraceSourceLocationResolver + and have both the AzureDevOps and GitHub Actions reporters use it, removing + two copies of the hardcoded MSTest assertion FQN list. +- Document that the assertion-frame skip is only needed on .NET Framework + (MSTest's Assert/CollectionAssert/StringAssert are [StackTraceHidden], which + the CLR honors on .NET Core 2.1+ so those frames are already absent) and gate + it at runtime via SkipAssertionFramesForCurrentRuntime. This is a runtime + check on purpose: the extensions ship as netstandard2.0 and that build loads + under .NET Framework, so a compile-time #if NETFRAMEWORK would never apply. + Tests force the skip on explicitly so coverage is unchanged. +- SlowTestReporterBase: keep the first-seen start time on repeated InProgress + updates (TryAdd) so a slow test can't be masked by progress heartbeats, and + skip emitting a slow-test notice for a test that completed mid-scan. + +Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> +--- + .../AzureDevOpsReporter.cs | 153 ++-------------- + ...esting.Extensions.AzureDevOpsReport.csproj | 1 + + .../GitHubActionsAnnotationReporter.cs | 118 +----------- + ...ting.Extensions.GitHubActionsReport.csproj | 1 + + .../SlowTestReporterBase.cs | 14 +- + .../StackTraceSourceLocationResolver.cs | 172 ++++++++++++++++++ + .../GitHubActionsAnnotationReporterTests.cs | 12 +- + 7 files changed, 212 insertions(+), 259 deletions(-) + create mode 100644 src/Platform/SharedExtensionHelpers/StackTraceSourceLocationResolver.cs + +diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsReporter.cs b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsReporter.cs +index ede2014a85..84817cc67b 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsReporter.cs ++++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsReporter.cs +@@ -3,7 +3,6 @@ + + using Microsoft.Testing.Extensions.AzureDevOpsReport.Resources; + using Microsoft.Testing.Extensions.Reporting; +-using Microsoft.Testing.Platform; + using Microsoft.Testing.Platform.CommandLine; + using Microsoft.Testing.Platform.Extensions; + using Microsoft.Testing.Platform.Extensions.Messages; +@@ -20,26 +19,9 @@ internal sealed class AzureDevOpsReporter : + IOutputDeviceDataProducer + { + internal const double KnownFlakyFailureRateThreshold = 0.25; +- private const string DeterministicBuildRoot = "/_/"; + private const int MinSamplesForRegressionAnnotation = 5; + private const string QuarantineBuildTagLine = "##vso[build.addbuildtag]has-quarantined-test-failure"; + private const string WarningSeverity = "warning"; +- private static readonly char[] NewlineCharacters = ['\r', '\n']; +- +- // Fully-qualified type prefixes for MSTest assertion implementations. A stack frame whose +- // 'code' (i.e., the "Namespace.Type.Method(args)" portion) starts with any of these is treated +- // as framework internals and skipped when looking for the user's call site to annotate. +- // Matching on the type name (rather than the source file name) is robust to partial-class +- // splits (e.g. Assert.AreEqual.cs, Assert.IComparable.cs) and extension-based assertion +- // implementations such as Assert.That in Assert.That.cs, and it avoids false positives on user +- // files innocently named *Assert.cs. See https://github.com/microsoft/testfx/issues/6925. +- private static readonly string[] AssertionImplementationCodePrefixes = +- [ +- "Microsoft.VisualStudio.TestTools.UnitTesting.Assert.", +- "Microsoft.VisualStudio.TestTools.UnitTesting.AssertExtensions.", +- "Microsoft.VisualStudio.TestTools.UnitTesting.CollectionAssert.", +- "Microsoft.VisualStudio.TestTools.UnitTesting.StringAssert.", +- ]; + + private readonly IOutputDevice _outputDisplay; + private readonly ILogger _logger; +@@ -170,7 +152,7 @@ private async Task WriteExceptionAsync(string testDisplayName, string testName, + + string severity = GetSeverity(testName, isQuarantined); + string annotationSuffix = BuildAnnotationSuffix(testName, isQuarantined); +- string? line = GetErrorText(testDisplayName, explanation, exception, severity, _fileSystem, _logger, _targetFrameworkMoniker, annotationSuffix, _userStackFrameFilters); ++ string? line = GetErrorText(testDisplayName, explanation, exception, severity, _fileSystem, _logger, _targetFrameworkMoniker, annotationSuffix, _userStackFrameFilters, StackTraceSourceLocationResolver.SkipAssertionFramesForCurrentRuntime); + if (line is null) + { + if (_logger.IsEnabled(LogLevel.Trace)) +@@ -190,12 +172,12 @@ private async Task WriteExceptionAsync(string testDisplayName, string testName, + } + + internal static /* for testing */ string? GetErrorText(string testDisplayName, string? explanation, Exception? exception, string severity, IFileSystem fileSystem, ILogger logger, string targetFrameworkMoniker) +- => GetErrorText(testDisplayName, explanation, exception, severity, fileSystem, logger, targetFrameworkMoniker, additionalMessageSuffix: null, userStackFrameFilters: null); ++ => GetErrorText(testDisplayName, explanation, exception, severity, fileSystem, logger, targetFrameworkMoniker, additionalMessageSuffix: null, userStackFrameFilters: null, skipAssertionFrames: true); + + internal static /* for testing */ string? GetErrorText(string testDisplayName, string? explanation, Exception? exception, string severity, IFileSystem fileSystem, ILogger logger, string targetFrameworkMoniker, string? additionalMessageSuffix) +- => GetErrorText(testDisplayName, explanation, exception, severity, fileSystem, logger, targetFrameworkMoniker, additionalMessageSuffix, userStackFrameFilters: null); ++ => GetErrorText(testDisplayName, explanation, exception, severity, fileSystem, logger, targetFrameworkMoniker, additionalMessageSuffix, userStackFrameFilters: null, skipAssertionFrames: true); + +- internal static /* for testing */ string? GetErrorText(string testDisplayName, string? explanation, Exception? exception, string severity, IFileSystem fileSystem, ILogger logger, string targetFrameworkMoniker, string? additionalMessageSuffix, Regex[]? userStackFrameFilters) ++ internal static /* for testing */ string? GetErrorText(string testDisplayName, string? explanation, Exception? exception, string severity, IFileSystem fileSystem, ILogger logger, string targetFrameworkMoniker, string? additionalMessageSuffix, Regex[]? userStackFrameFilters, bool skipAssertionFrames) + { + string message = explanation ?? exception?.Message ?? AzureDevOpsResources.NoFailureMessageFallback; + string formattedMessage = $"{FormatErrorMessage(testDisplayName, targetFrameworkMoniker, message)}{additionalMessageSuffix}"; +@@ -208,87 +190,17 @@ private async Task WriteExceptionAsync(string testDisplayName, string testName, + logger.LogTrace($"Found repo root '{repoRoot}'"); + } + +- foreach (string? stackFrame in stackTrace.Split(NewlineCharacters, StringSplitOptions.RemoveEmptyEntries)) +- { +- (string Code, string File, int LineNumber)? location = GetStackFrameLocation(stackFrame); +- if (location is null) +- { +- if (logger.IsEnabled(LogLevel.Trace)) +- { +- logger.LogTrace("StackFrame location was null, continuing to next."); +- } +- +- continue; +- } +- +- string file = location.Value.File; +- string code = location.Value.Code; +- +- if (IsAssertionImplementationFrame(code) || IsUserStackFrameFilterMatch(code, userStackFrameFilters, logger)) +- { +- if (logger.IsEnabled(LogLevel.Trace)) +- { +- logger.LogTrace($"StackFrame code '{code}' is an MSTest assertion implementation, continuing to next."); +- } +- +- continue; +- } +- +- string relativePath; +- if (file.StartsWith(DeterministicBuildRoot, StringComparison.OrdinalIgnoreCase)) +- { +- if (logger.IsEnabled(LogLevel.Trace)) +- { +- logger.LogTrace($"Path '{file}' is coming from deterministic build."); +- } +- +- relativePath = file.Substring(DeterministicBuildRoot.Length); +- if (logger.IsEnabled(LogLevel.Trace)) +- { +- logger.LogTrace($"Using relative path '{relativePath}'."); +- } +- } +- else if (file.StartsWith(repoRoot, StringComparison.OrdinalIgnoreCase)) +- { +- if (logger.IsEnabled(LogLevel.Trace)) +- { +- logger.LogTrace($"Path '{file}' is in current repo '{repoRoot}'."); +- } +- +- relativePath = file.Substring(repoRoot.Length); +- if (logger.IsEnabled(LogLevel.Trace)) +- { +- logger.LogTrace($"Using relative path '{relativePath}'."); +- } +- } +- else +- { +- if (logger.IsEnabled(LogLevel.Trace)) +- { +- logger.LogTrace($"Path '{file}' does not belong to current repo '{repoRoot}'. Continue to next."); +- } +- +- continue; +- } +- +- string fullPath = Path.Combine(repoRoot, relativePath); +- if (!fileSystem.ExistFile(fullPath)) +- { +- if (logger.IsEnabled(LogLevel.Trace)) +- { +- logger.LogTrace($"Path '{fullPath}' does not exist on disk. Continue to next."); +- } +- +- continue; +- } +- +- string relativeNormalizedPath = relativePath.Replace('\\', '/'); +- if (logger.IsEnabled(LogLevel.Trace)) +- { +- logger.LogTrace($"Normalized path for GitHub '{relativeNormalizedPath}'."); +- } ++ (string RelativeNormalizedPath, int LineNumber)? location = StackTraceSourceLocationResolver.TryResolve( ++ stackTrace, ++ repoRoot, ++ fileSystem, ++ logger, ++ skipAssertionFrames, ++ code => IsUserStackFrameFilterMatch(code, userStackFrameFilters, logger)); + +- string line = $"##vso[task.logissue type={severity};sourcepath={relativeNormalizedPath};linenumber={location.Value.LineNumber};columnnumber=1]{AzDoEscaper.Escape(formattedMessage)}"; ++ if (location is not null) ++ { ++ string line = $"##vso[task.logissue type={severity};sourcepath={location.Value.RelativeNormalizedPath};linenumber={location.Value.LineNumber};columnnumber=1]{AzDoEscaper.Escape(formattedMessage)}"; + if (logger.IsEnabled(LogLevel.Trace)) + { + logger.LogTrace($"Reported full message '{line}'."); +@@ -463,19 +375,6 @@ private static bool DisplayNameContainsTfm(string displayName, string tfm) + => displayName.EndsWith($"({tfm})", StringComparison.Ordinal) + || displayName.EndsWith($"(\"{tfm}\")", StringComparison.Ordinal); + +- private static bool IsAssertionImplementationFrame(string code) +- { +- foreach (string prefix in AssertionImplementationCodePrefixes) +- { +- if (code.StartsWith(prefix, StringComparison.Ordinal)) +- { +- return true; +- } +- } +- +- return false; +- } +- + private static bool IsUserStackFrameFilterMatch(string code, Regex[]? userStackFrameFilters, ILogger logger) + { + if (userStackFrameFilters is null || userStackFrameFilters.Length == 0) +@@ -503,28 +402,4 @@ private static bool IsUserStackFrameFilterMatch(string code, Regex[]? userStackF + + return false; + } +- +- private static (string Code, string File, int LineNumber)? GetStackFrameLocation(string stackTraceLine) +- { +- Match match = StackTraceHelper.GetFrameRegex().Match(stackTraceLine); +- if (!match.Success) +- { +- return null; +- } +- +- string code = match.Groups["code"].Value; +- if (RoslynString.IsNullOrWhiteSpace(code)) +- { +- return null; +- } +- +- string file = match.Groups["file"].Value; +- if (RoslynString.IsNullOrWhiteSpace(file)) +- { +- return null; +- } +- +- int line = int.TryParse(match.Groups["line"].Value, out int value) ? value : 0; +- return (code, file, line); +- } + } +diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Microsoft.Testing.Extensions.AzureDevOpsReport.csproj b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Microsoft.Testing.Extensions.AzureDevOpsReport.csproj +index a6ce74a8ef..0b5d6b5a40 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Microsoft.Testing.Extensions.AzureDevOpsReport.csproj ++++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Microsoft.Testing.Extensions.AzureDevOpsReport.csproj +@@ -15,6 +15,7 @@ + + + ++ + + + +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsAnnotationReporter.cs b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsAnnotationReporter.cs +index 3ab013ae6a..5a003fcd65 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsAnnotationReporter.cs ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsAnnotationReporter.cs +@@ -2,7 +2,6 @@ + // Licensed under the MIT license. See LICENSE file in the project root for full license information. + + using Microsoft.Testing.Extensions.GitHubActionsReport.Resources; +-using Microsoft.Testing.Platform; + using Microsoft.Testing.Platform.CommandLine; + using Microsoft.Testing.Platform.Extensions; + using Microsoft.Testing.Platform.Extensions.Messages; +@@ -22,20 +21,6 @@ internal sealed class GitHubActionsAnnotationReporter : + IDataConsumer, + IOutputDeviceDataProducer + { +- private const string DeterministicBuildRoot = "/_/"; +- private static readonly char[] NewlineCharacters = ['\r', '\n']; +- +- // Fully-qualified type prefixes for MSTest assertion implementations. A stack frame whose +- // 'code' starts with any of these is treated as framework internals and skipped when looking +- // for the user's call site to annotate. +- private static readonly string[] AssertionImplementationCodePrefixes = +- [ +- "Microsoft.VisualStudio.TestTools.UnitTesting.Assert.", +- "Microsoft.VisualStudio.TestTools.UnitTesting.AssertExtensions.", +- "Microsoft.VisualStudio.TestTools.UnitTesting.CollectionAssert.", +- "Microsoft.VisualStudio.TestTools.UnitTesting.StringAssert.", +- ]; +- + private readonly IEnvironment _environment; + private readonly IFileSystem _fileSystem; + private readonly IOutputDevice _outputDisplay; +@@ -128,7 +113,7 @@ private async Task WriteAnnotationAsync(string testName, string? explanation, Ex + } + + string repoRoot = GitHubActionsRepositoryRoot.Resolve(_environment) ?? string.Empty; +- string line = GetErrorAnnotation(testName, explanation, exception, repoRoot, _fileSystem, _logger); ++ string line = GetErrorAnnotation(testName, explanation, exception, repoRoot, _fileSystem, _logger, StackTraceSourceLocationResolver.SkipAssertionFramesForCurrentRuntime); + + if (_logger.IsEnabled(LogLevel.Trace)) + { +@@ -144,19 +129,19 @@ private async Task WriteAnnotationAsync(string testName, string? explanation, Ex + await _outputDisplay.DisplayAsync(this, new FormattedTextOutputDeviceData($"\n{line}"), cancellationToken).ConfigureAwait(false); + } + +- internal static /* for testing */ string GetErrorAnnotation(string testName, string? explanation, Exception? exception, string? repoRoot, IFileSystem fileSystem, ILogger logger) ++ internal static /* for testing */ string GetErrorAnnotation(string testName, string? explanation, Exception? exception, string? repoRoot, IFileSystem fileSystem, ILogger logger, bool skipAssertionFrames) + { + string message = explanation ?? exception?.Message ?? GitHubActionsResources.NoFailureMessageFallback; + string title = string.Format(CultureInfo.InvariantCulture, GitHubActionsResources.AnnotationTitle, testName); + +- (string File, int Line)? location = TryGetSourceLocation(exception, repoRoot, fileSystem, logger); ++ (string RelativeNormalizedPath, int LineNumber)? location = StackTraceSourceLocationResolver.TryResolve(exception?.StackTrace, repoRoot, fileSystem, logger, skipAssertionFrames); + if (location is not null) + { + return string.Format( + CultureInfo.InvariantCulture, + "::error file={0},line={1},col=1,title={2}::{3}", +- GitHubActionsEscaper.EscapeProperty(location.Value.File), +- location.Value.Line.ToString(CultureInfo.InvariantCulture), ++ GitHubActionsEscaper.EscapeProperty(location.Value.RelativeNormalizedPath), ++ location.Value.LineNumber.ToString(CultureInfo.InvariantCulture), + GitHubActionsEscaper.EscapeProperty(title), + GitHubActionsEscaper.EscapeData(message)); + } +@@ -170,99 +155,6 @@ private async Task WriteAnnotationAsync(string testName, string? explanation, Ex + GitHubActionsEscaper.EscapeData(message)); + } + +- private static (string File, int Line)? TryGetSourceLocation(Exception? exception, string? repoRoot, IFileSystem fileSystem, ILogger logger) +- { +- if (exception?.StackTrace is not { } stackTrace || RoslynString.IsNullOrEmpty(repoRoot)) +- { +- return null; +- } +- +- foreach (string stackFrame in stackTrace.Split(NewlineCharacters, StringSplitOptions.RemoveEmptyEntries)) +- { +- (string Code, string File, int LineNumber)? location = GetStackFrameLocation(stackFrame); +- if (location is null) +- { +- continue; +- } +- +- string file = location.Value.File; +- string code = location.Value.Code; +- +- if (IsAssertionImplementationFrame(code)) +- { +- continue; +- } +- +- string relativePath; +- if (file.StartsWith(DeterministicBuildRoot, StringComparison.OrdinalIgnoreCase)) +- { +- relativePath = file.Substring(DeterministicBuildRoot.Length); +- } +- else if (file.StartsWith(repoRoot, StringComparison.OrdinalIgnoreCase)) +- { +- relativePath = file.Substring(repoRoot.Length); +- } +- else +- { +- continue; +- } +- +- string fullPath = Path.Combine(repoRoot, relativePath); +- if (!fileSystem.ExistFile(fullPath)) +- { +- continue; +- } +- +- // GitHub annotations expect a workspace-relative path with forward slashes. +- string relativeNormalizedPath = relativePath.Replace('\\', '/').TrimStart('/'); +- if (logger.IsEnabled(LogLevel.Trace)) +- { +- logger.LogTrace($"Normalized path for GitHub annotation '{relativeNormalizedPath}'."); +- } +- +- return (relativeNormalizedPath, location.Value.LineNumber); +- } +- +- return null; +- } +- +- private static bool IsAssertionImplementationFrame(string code) +- { +- foreach (string prefix in AssertionImplementationCodePrefixes) +- { +- if (code.StartsWith(prefix, StringComparison.Ordinal)) +- { +- return true; +- } +- } +- +- return false; +- } +- +- private static (string Code, string File, int LineNumber)? GetStackFrameLocation(string stackTraceLine) +- { +- Match match = StackTraceHelper.GetFrameRegex().Match(stackTraceLine); +- if (!match.Success) +- { +- return null; +- } +- +- string code = match.Groups["code"].Value; +- if (RoslynString.IsNullOrWhiteSpace(code)) +- { +- return null; +- } +- +- string file = match.Groups["file"].Value; +- if (RoslynString.IsNullOrWhiteSpace(file)) +- { +- return null; +- } +- +- int line = int.TryParse(match.Groups["line"].Value, out int value) ? value : 0; +- return (code, file, line); +- } +- + private static string GetTestName(TestNode testNode) + => TestNodeIdentity.GetTestName(testNode); + +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Microsoft.Testing.Extensions.GitHubActionsReport.csproj b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Microsoft.Testing.Extensions.GitHubActionsReport.csproj +index 80ae47d443..ed32e39de2 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Microsoft.Testing.Extensions.GitHubActionsReport.csproj ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Microsoft.Testing.Extensions.GitHubActionsReport.csproj +@@ -18,6 +18,7 @@ + + + ++ + + + +diff --git a/src/Platform/SharedExtensionHelpers/SlowTestReporterBase.cs b/src/Platform/SharedExtensionHelpers/SlowTestReporterBase.cs +index 8556840d86..d886c6df43 100644 +--- a/src/Platform/SharedExtensionHelpers/SlowTestReporterBase.cs ++++ b/src/Platform/SharedExtensionHelpers/SlowTestReporterBase.cs +@@ -116,7 +116,11 @@ public Task ConsumeAsync(IDataProducer dataProducer, IData value, CancellationTo + { + string testName = GetTestName(update.TestNode); + TimeSpan threshold = ResolveThreshold(testName); +- _inProgress[uid] = new InProgressTest(testName, _clock.UtcNow, threshold); ++ ++ // Use the first-seen start time: the platform can emit InProgress more than once for the same ++ // test (progress heartbeats), and resetting the start time on each would keep pushing the slow ++ // threshold out so a genuinely slow test would never surface. ++ _inProgress.TryAdd(uid, new InProgressTest(testName, _clock.UtcNow, threshold)); + } + else if (state is not null) + { +@@ -221,6 +225,14 @@ internal async Task ScanOnceAsync(DateTimeOffset now, CancellationToken cancella + continue; + } + ++ // The enumeration is a moving snapshot of the ConcurrentDictionary; a test can complete (and be ++ // removed) between the snapshot and here. Skip it so we don't surface a slow-test notice for a ++ // test that has already finished. ++ if (!_inProgress.ContainsKey(entry.Key)) ++ { ++ continue; ++ } ++ + // Exponential backoff so a genuinely stuck test does not spam the log: T, 2T, 4T, ... + // Clamp at TimeSpan.MaxValue so a very long-running test cannot overflow Ticks * 2 into a + // negative value (which would make the backoff fire on every scan). +diff --git a/src/Platform/SharedExtensionHelpers/StackTraceSourceLocationResolver.cs b/src/Platform/SharedExtensionHelpers/StackTraceSourceLocationResolver.cs +new file mode 100644 +index 0000000000..4f7bcede9f +--- /dev/null ++++ b/src/Platform/SharedExtensionHelpers/StackTraceSourceLocationResolver.cs +@@ -0,0 +1,172 @@ ++// Copyright (c) Microsoft Corporation. All rights reserved. ++// Licensed under the MIT license. See LICENSE file in the project root for full license information. ++ ++using Microsoft.Testing.Platform; ++using Microsoft.Testing.Platform.Helpers; ++using Microsoft.Testing.Platform.Logging; ++ ++namespace Microsoft.Testing.Extensions; ++ ++/// ++/// Resolves the first user source location (workspace-relative file path + line) from an exception stack ++/// trace, so a failure can be annotated on the reporting host (Azure DevOps ##vso[task.logissue] or ++/// GitHub Actions ::error). Shared by the Azure DevOps and GitHub Actions reporters. ++/// ++internal static class StackTraceSourceLocationResolver ++{ ++ // Source-linked (deterministic) builds emit paths rooted at '/_/' instead of the original absolute path. ++ private const string DeterministicBuildRoot = "/_/"; ++ ++ private static readonly char[] NewlineCharacters = ['\r', '\n']; ++ ++ // Fully-qualified type prefixes for MSTest assertion implementations. A stack frame whose 'code' starts ++ // with any of these is treated as framework internals and skipped when looking for the user's call site to ++ // annotate. Matching on the type name (rather than the source file name) is robust to partial-class splits ++ // (e.g. Assert.AreEqual.cs, Assert.IComparable.cs) and extension-based assertion implementations such as ++ // Assert.That in Assert.That.cs, and it avoids false positives on user files innocently named *Assert.cs. ++ // See https://github.com/microsoft/testfx/issues/6925. ++ private static readonly string[] AssertionImplementationCodePrefixes = ++ [ ++ "Microsoft.VisualStudio.TestTools.UnitTesting.Assert.", ++ "Microsoft.VisualStudio.TestTools.UnitTesting.AssertExtensions.", ++ "Microsoft.VisualStudio.TestTools.UnitTesting.CollectionAssert.", ++ "Microsoft.VisualStudio.TestTools.UnitTesting.StringAssert.", ++ ]; ++ ++ /// ++ /// Gets a value indicating whether MSTest assertion frames must be skipped manually on the current runtime. ++ /// ++ /// ++ /// MSTest's Assert, CollectionAssert, and StringAssert are all marked ++ /// [StackTraceHidden], which the CLR honors on .NET Core 2.1+ (i.e. every modern TFM) by omitting ++ /// those frames from altogether — so nothing needs skipping there. ++ /// Only .NET Framework ignores [StackTraceHidden] and still surfaces the assertion frames, which is ++ /// why the manual skip only earns its keep there. ++ /// ++ /// This is a runtime check on purpose: these extensions ship as netstandard2.0 and that build ++ /// is what loads under .NET Framework, so a compile-time #if NETFRAMEWORK would never be defined for ++ /// the running assembly. ++ /// ++ /// ++ public static bool SkipAssertionFramesForCurrentRuntime { get; } = ++ System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription.StartsWith(".NET Framework", StringComparison.OrdinalIgnoreCase); ++ ++ /// ++ /// Walks and returns the first frame that resolves to an existing file under ++ /// (or a deterministic-build path), as a workspace-relative, forward-slash path ++ /// plus its line number. Returns when no such frame can be resolved. ++ /// ++ /// The exception stack trace string, or . ++ /// The repository root used to relativize absolute paths (should end with a separator), or . ++ /// File system used to verify the candidate file exists on disk. ++ /// Logger for trace diagnostics. ++ /// ++ /// When , frames whose code matches a known MSTest assertion implementation are ++ /// skipped. Production callers pass . ++ /// ++ /// Optional additional per-frame predicate (e.g. host-specific user filters). ++ public static (string RelativeNormalizedPath, int LineNumber)? TryResolve( ++ string? stackTrace, ++ string? repoRoot, ++ IFileSystem fileSystem, ++ ILogger logger, ++ bool skipAssertionFrames, ++ Func? shouldSkipFrame = null) ++ { ++ if (RoslynString.IsNullOrEmpty(stackTrace) || RoslynString.IsNullOrEmpty(repoRoot)) ++ { ++ return null; ++ } ++ ++ foreach (string stackFrame in stackTrace!.Split(NewlineCharacters, StringSplitOptions.RemoveEmptyEntries)) ++ { ++ (string Code, string File, int LineNumber)? location = GetStackFrameLocation(stackFrame); ++ if (location is null) ++ { ++ continue; ++ } ++ ++ string file = location.Value.File; ++ string code = location.Value.Code; ++ ++ if ((skipAssertionFrames && IsAssertionImplementationFrame(code)) ++ || (shouldSkipFrame is not null && shouldSkipFrame(code))) ++ { ++ if (logger.IsEnabled(LogLevel.Trace)) ++ { ++ logger.LogTrace($"Skipping stack frame '{code}' while resolving the source location."); ++ } ++ ++ continue; ++ } ++ ++ string relativePath; ++ if (file.StartsWith(DeterministicBuildRoot, StringComparison.OrdinalIgnoreCase)) ++ { ++ relativePath = file.Substring(DeterministicBuildRoot.Length); ++ } ++ else if (file.StartsWith(repoRoot!, StringComparison.OrdinalIgnoreCase)) ++ { ++ relativePath = file.Substring(repoRoot!.Length); ++ } ++ else ++ { ++ continue; ++ } ++ ++ string fullPath = Path.Combine(repoRoot!, relativePath); ++ if (!fileSystem.ExistFile(fullPath)) ++ { ++ continue; ++ } ++ ++ // Annotations expect a workspace-relative path with forward slashes. ++ string relativeNormalizedPath = relativePath.Replace('\\', '/').TrimStart('/'); ++ if (logger.IsEnabled(LogLevel.Trace)) ++ { ++ logger.LogTrace($"Resolved source location '{relativeNormalizedPath}' (line {location.Value.LineNumber})."); ++ } ++ ++ return (relativeNormalizedPath, location.Value.LineNumber); ++ } ++ ++ return null; ++ } ++ ++ private static bool IsAssertionImplementationFrame(string code) ++ { ++ foreach (string prefix in AssertionImplementationCodePrefixes) ++ { ++ if (code.StartsWith(prefix, StringComparison.Ordinal)) ++ { ++ return true; ++ } ++ } ++ ++ return false; ++ } ++ ++ private static (string Code, string File, int LineNumber)? GetStackFrameLocation(string stackTraceLine) ++ { ++ Match match = StackTraceHelper.GetFrameRegex().Match(stackTraceLine); ++ if (!match.Success) ++ { ++ return null; ++ } ++ ++ string code = match.Groups["code"].Value; ++ if (RoslynString.IsNullOrWhiteSpace(code)) ++ { ++ return null; ++ } ++ ++ string file = match.Groups["file"].Value; ++ if (RoslynString.IsNullOrWhiteSpace(file)) ++ { ++ return null; ++ } ++ ++ int line = int.TryParse(match.Groups["line"].Value, out int value) ? value : 0; ++ return (code, file, line); ++ } ++} +diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsAnnotationReporterTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsAnnotationReporterTests.cs +index 13c4648af7..4aeddaa797 100644 +--- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsAnnotationReporterTests.cs ++++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsAnnotationReporterTests.cs +@@ -21,7 +21,7 @@ public void GetErrorAnnotation_ReportsResolvedFileWithLineColTitleAndEscaping() + Exception error = CaptureException("this is an error\nwith\rnewline", out int throwLine); + + string text = GitHubActionsAnnotationReporter.GetErrorAnnotation( +- "MyNamespace.MyTest", explanation: null, error, GitHubActionsRepositoryRoot.FindGitRoot(), CreateFileSystemWhereEveryFileExists(), new NoopLogger()); ++ "MyNamespace.MyTest", explanation: null, error, GitHubActionsRepositoryRoot.FindGitRoot(), CreateFileSystemWhereEveryFileExists(), new NoopLogger(), skipAssertionFrames: true); + + // The line is computed dynamically (from where the throw actually executes) rather than hard-coded, and the + // file existence is mocked, so the assertion does not depend on this file's exact layout or the physical +@@ -37,7 +37,7 @@ public void GetErrorAnnotation_PrefersExplanationOverExceptionMessage() + Exception error = CaptureException("exception message", out int throwLine); + + string text = GitHubActionsAnnotationReporter.GetErrorAnnotation( +- "MyNamespace.MyTest", "Some custom reason\nwith\rnewline", error, GitHubActionsRepositoryRoot.FindGitRoot(), CreateFileSystemWhereEveryFileExists(), new NoopLogger()); ++ "MyNamespace.MyTest", "Some custom reason\nwith\rnewline", error, GitHubActionsRepositoryRoot.FindGitRoot(), CreateFileSystemWhereEveryFileExists(), new NoopLogger(), skipAssertionFrames: true); + + Assert.IsTrue(text.StartsWith("::error file=", StringComparison.Ordinal), text); + Assert.Contains($"GitHubActionsAnnotationReporterTests.cs,line={throwLine},col=1,title=Test failed%3A MyNamespace.MyTest::", text); +@@ -47,7 +47,7 @@ public void GetErrorAnnotation_PrefersExplanationOverExceptionMessage() + [TestMethod] + public void GetErrorAnnotation_FallsBackToTitleOnly_WhenNoSourceLocation() + { +- string text = GitHubActionsAnnotationReporter.GetErrorAnnotation("MyNamespace.MyTest", "boom", exception: null, repoRoot: null, CreateFileSystemWhereEveryFileExists(), new NoopLogger()); ++ string text = GitHubActionsAnnotationReporter.GetErrorAnnotation("MyNamespace.MyTest", "boom", exception: null, repoRoot: null, CreateFileSystemWhereEveryFileExists(), new NoopLogger(), skipAssertionFrames: true); + + Assert.AreEqual("::error title=Test failed%3A MyNamespace.MyTest::boom", text); + } +@@ -60,7 +60,7 @@ public void GetErrorAnnotation_RemapsDeterministicBuildRootPathToWorkspaceRelati + var exception = new StackTraceException(" at Contoso.Calc.Add() in /_/src/Calc.cs:line 12"); + + string text = GitHubActionsAnnotationReporter.GetErrorAnnotation( +- "Contoso.CalcTests.Add", "boom", exception, repoRoot: "/repo/", CreateFileSystemWhereEveryFileExists(), new NoopLogger()); ++ "Contoso.CalcTests.Add", "boom", exception, repoRoot: "/repo/", CreateFileSystemWhereEveryFileExists(), new NoopLogger(), skipAssertionFrames: true); + + Assert.AreEqual("::error file=src/Calc.cs,line=12,col=1,title=Test failed%3A Contoso.CalcTests.Add::boom", text); + } +@@ -74,7 +74,7 @@ public void GetErrorAnnotation_SkipsAssertionFramesAndAnnotatesUserCallSite() + + " at Contoso.MyTests.TheTest() in /_/src/MyTests.cs:line 7"); + + string text = GitHubActionsAnnotationReporter.GetErrorAnnotation( +- "Contoso.MyTests.TheTest", "nope", exception, repoRoot: "/repo/", CreateFileSystemWhereEveryFileExists(), new NoopLogger()); ++ "Contoso.MyTests.TheTest", "nope", exception, repoRoot: "/repo/", CreateFileSystemWhereEveryFileExists(), new NoopLogger(), skipAssertionFrames: true); + + Assert.AreEqual("::error file=src/MyTests.cs,line=7,col=1,title=Test failed%3A Contoso.MyTests.TheTest::nope", text); + } +@@ -82,7 +82,7 @@ public void GetErrorAnnotation_SkipsAssertionFramesAndAnnotatesUserCallSite() + [TestMethod] + public void GetErrorAnnotation_UsesFallbackMessage_WhenNoExplanationOrException() + { +- string text = GitHubActionsAnnotationReporter.GetErrorAnnotation("MyNamespace.MyTest", explanation: null, exception: null, repoRoot: null, CreateFileSystemWhereEveryFileExists(), new NoopLogger()); ++ string text = GitHubActionsAnnotationReporter.GetErrorAnnotation("MyNamespace.MyTest", explanation: null, exception: null, repoRoot: null, CreateFileSystemWhereEveryFileExists(), new NoopLogger(), skipAssertionFrames: true); + + Assert.IsTrue(text.StartsWith("::error title=Test failed%3A MyNamespace.MyTest::", StringComparison.Ordinal), text); + } + +From c51947fa77f7096884f990d4ccd7b0d5f92f6d92 Mon Sep 17 00:00:00 2001 +From: Copilot App <223556219+Copilot@users.noreply.github.com> +Date: Fri, 3 Jul 2026 14:54:11 +0200 +Subject: [PATCH 31/32] Address review comments: PACKAGE.md activation wording + and test clarity + +- PACKAGE.md: describe activation accurately (requires GITHUB_ACTIONS=true and + --report-gh, AND semantics) instead of implying auto-on-CI, and document the + slow-test threshold duration format (60s/2m/1.5h). +- Summary test: express the slowest-first ordering assertion with IsLessThan and + a comment so the intended slowIndex < fastIndex relationship is unambiguous. + +Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> +--- + .../PACKAGE.md | 7 +++---- + .../GitHubActionsSummaryReporterTests.cs | 6 +++++- + 2 files changed, 8 insertions(+), 5 deletions(-) + +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/PACKAGE.md b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/PACKAGE.md +index 4519828b44..0d674a4040 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/PACKAGE.md ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/PACKAGE.md +@@ -18,18 +18,17 @@ This package extends Microsoft.Testing.Platform with: + - **Failure annotations**: emits an `::error` workflow command for each failing test so failures appear in the workflow Annotations tab and, when the source location can be resolved, on the pull request's "Files changed" diff gutter + - **Job summary**: appends a markdown roll-up (totals, failures, slowest tests) to the file pointed to by `GITHUB_STEP_SUMMARY`, which GitHub renders on the workflow run summary page + - **Slow-test notices**: emits a `::notice` workflow command for any test still running past a threshold (default 60 seconds) +-- **CI auto-detection**: detects GitHub Actions environments through the `GITHUB_ACTIONS` variable + +-All features are enabled by default when running on GitHub Actions (or when the `--report-gh` master switch is set), and no-op otherwise. Each feature can be toggled individually: ++The extension activates when the test run is on GitHub Actions (`GITHUB_ACTIONS=true`) and the `--report-gh` switch is passed; it no-ops otherwise. When active, each feature is enabled by default and can be toggled individually: + + | Option | Description | Default | + |---|---|---| +-| `--report-gh` | Master switch to enable the extension outside GitHub Actions | off | ++| `--report-gh` | Master switch that turns the extension on (required, in addition to running on GitHub Actions) | off | + | `--report-gh-groups on\|off` | Per-assembly log groups | on | + | `--report-gh-annotations on\|off` | Failure annotations | on | + | `--report-gh-step-summary on\|off` | Markdown job summary | on | + | `--report-gh-slow-test-notices on\|off` | Slow-test notices | on | +-| `--report-gh-slow-test-threshold ` | Seconds before a slow-test notice is emitted | 60 | ++| `--report-gh-slow-test-threshold ` | Time before a slow-test notice is emitted; accepts a bare number of seconds or a unit suffix such as `90s`, `2m`, `1.5h` | 60s | + + ## Related packages + +diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsSummaryReporterTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsSummaryReporterTests.cs +index 4fa02ae536..06ac66715e 100644 +--- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsSummaryReporterTests.cs ++++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsSummaryReporterTests.cs +@@ -58,7 +58,11 @@ public void BuildMarkdown_EmitsSlowestTestsSortedByDuration() + int slowIndex = markdown.IndexOf("- `T.Slow` — 1m 05s", StringComparison.Ordinal); + int fastIndex = markdown.IndexOf("- `T.Fast` — 10ms", StringComparison.Ordinal); + Assert.IsGreaterThanOrEqualTo(0, slowIndex, markdown); +- Assert.IsGreaterThan(slowIndex, fastIndex, markdown); ++ Assert.IsGreaterThanOrEqualTo(0, fastIndex, markdown); ++ ++ // Slowest-first ordering: the slow test must be listed before the fast one, i.e. at a smaller index. ++ // IsLessThan(upperBound, value) asserts value < upperBound, so this asserts slowIndex < fastIndex. ++ Assert.IsLessThan(fastIndex, slowIndex, markdown); + } + + [TestMethod] + +From 50c0d326df9bdf7c07a924f31cd5c7d9d4a2a4a7 Mon Sep 17 00:00:00 2001 +From: Copilot App <223556219+Copilot@users.noreply.github.com> +Date: Fri, 3 Jul 2026 15:13:54 +0200 +Subject: [PATCH 32/32] Preserve platform TFM in GitHub Actions reporters; + comment tweak + +- Drop the redundant GetTargetFrameworkMonikerWithRuntimeFallback helper and + use GetTargetFrameworkMonikerIncludingPlatform in the GitHub Actions group + and summary reporters (same as the AzureDevOps sibling). The IncludingPlatform + path already has the runtime fallback built in AND keeps the platform + component (e.g. net8.0-windows10.0.18362.0), so group titles / step-summary + output are no longer ambiguous for platform-specific TFMs. +- Reword the slow-test threshold comment for clarity. + +Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> +--- + .../GitHubActionsReporter.cs | 2 +- + .../GitHubActionsSlowTestReporter.cs | 8 ++++---- + .../GitHubActionsSummaryReporter.cs | 2 +- + .../TargetFrameworkMonikerHelper.cs | 5 ----- + 4 files changed, 6 insertions(+), 11 deletions(-) + +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsReporter.cs b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsReporter.cs +index 624323b4b4..ea8f06ebe2 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsReporter.cs ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsReporter.cs +@@ -53,7 +53,7 @@ public GitHubActionsReporter( + _outputDisplay = outputDisplay; + _testApplicationModuleInfo = testApplicationModuleInfo; + _logger = loggerFactory.CreateLogger(); +- _targetFrameworkMoniker = TargetFrameworkMonikerHelper.GetTargetFrameworkMonikerWithRuntimeFallback(); ++ _targetFrameworkMoniker = TargetFrameworkMonikerHelper.GetTargetFrameworkMonikerIncludingPlatform(); + } + + /// +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsSlowTestReporter.cs b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsSlowTestReporter.cs +index 24a0a30f92..1c6ac0bd3e 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsSlowTestReporter.cs ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsSlowTestReporter.cs +@@ -66,10 +66,10 @@ protected override Task EmitSlowTestAsync(string testName, TimeSpan elapsed, Can + GitHubActionsEscaper.EscapeData(message)); + } + +- // Re-reads the already-provider-validated threshold option (the CLI provider guarantees a parseable +- // positive duration). Accepts a bare number of seconds or a value with a unit suffix (e.g. '90s', '2m'). +- // This mirrors the sibling AzureDevOpsSlowTestReporter, which likewise reads its history options straight +- // from ICommandLineOptions rather than threading a parsed value through. ++ // Re-reads the threshold option, which the CLI provider has already validated (it guarantees a ++ // parseable positive duration). Accepts a bare number of seconds or a value with a unit suffix ++ // (e.g. '90s', '2m'). This mirrors the sibling AzureDevOpsSlowTestReporter, which likewise reads its ++ // history options straight from ICommandLineOptions rather than threading a parsed value through. + private static TimeSpan GetThreshold(ICommandLineOptions commandLineOptions) + => commandLineOptions.TryGetOptionArgumentList(GitHubActionsCommandLineOptions.GitHubActionsSlowTestThreshold, out string[]? arguments) + && arguments is [string value] +diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsSummaryReporter.cs b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsSummaryReporter.cs +index 9a565a2a81..e50aa4df41 100644 +--- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsSummaryReporter.cs ++++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/GitHubActionsSummaryReporter.cs +@@ -60,7 +60,7 @@ public GitHubActionsSummaryReporter( + _outputDevice = outputDevice; + _testApplicationModuleInfo = testApplicationModuleInfo; + _logger = loggerFactory.CreateLogger(); +- _targetFrameworkMoniker = new(TargetFrameworkMonikerHelper.GetTargetFrameworkMonikerWithRuntimeFallback); ++ _targetFrameworkMoniker = new(TargetFrameworkMonikerHelper.GetTargetFrameworkMonikerIncludingPlatform); + _isEnabled = GitHubActionsFeature.IsEnabled(commandLineOptions, environment, GitHubActionsCommandLineOptions.GitHubActionsStepSummary); + } + +diff --git a/src/Platform/SharedExtensionHelpers/TargetFrameworkMonikerHelper.cs b/src/Platform/SharedExtensionHelpers/TargetFrameworkMonikerHelper.cs +index b64bd6f8d5..03c9af9dcd 100644 +--- a/src/Platform/SharedExtensionHelpers/TargetFrameworkMonikerHelper.cs ++++ b/src/Platform/SharedExtensionHelpers/TargetFrameworkMonikerHelper.cs +@@ -10,9 +10,4 @@ internal static class TargetFrameworkMonikerHelper + public static string GetTargetFrameworkMonikerIncludingPlatform() + => TargetFrameworkParser.GetShortTargetFrameworkIncludingPlatform(Assembly.GetEntryAssembly()) + ?? "unknown framework"; +- +- public static string GetTargetFrameworkMonikerWithRuntimeFallback() +- => TargetFrameworkParser.GetShortTargetFramework(Assembly.GetEntryAssembly()?.GetCustomAttribute()?.FrameworkDisplayName) +- ?? TargetFrameworkParser.GetShortTargetFramework(System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription) +- ?? "unknown framework"; + } diff --git a/src/Package/MSTest.Sdk/MSTest.Sdk.csproj b/src/Package/MSTest.Sdk/MSTest.Sdk.csproj index 96df9602d8..61e60f15ea 100644 --- a/src/Package/MSTest.Sdk/MSTest.Sdk.csproj +++ b/src/Package/MSTest.Sdk/MSTest.Sdk.csproj @@ -13,14 +13,21 @@ This package contains the MSTest MSBuild project SDK. +<<<<<<< HEAD false true +======= + PACKAGE.md +>>>>>>> MSTest migrated $(NoWarn);NU5128 + + false +<<<<<<< HEAD + + + + + + + + + + + + + + + + + true + buildMultiTargeting + + + buildTransitive/$(TargetFramework) + + + build/$(TargetFramework) + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/PACKAGE.md b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/PACKAGE.md new file mode 100644 index 0000000000..4519828b44 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/PACKAGE.md @@ -0,0 +1,44 @@ +# Microsoft.Testing.Extensions.GitHubActionsReport + +Microsoft.Testing.Extensions.GitHubActionsReport is an extension for [Microsoft.Testing.Platform](https://www.nuget.org/packages/Microsoft.Testing.Platform) that emits GitHub Actions-native workflow commands so test runs on GitHub Actions produce a first-class experience. + +Microsoft.Testing.Platform is open source. You can find `Microsoft.Testing.Extensions.GitHubActionsReport` code in the [microsoft/testfx](https://github.com/microsoft/testfx) GitHub repository. + +## Install the package + +```dotnetcli +dotnet add package Microsoft.Testing.Extensions.GitHubActionsReport +``` + +## About + +This package extends Microsoft.Testing.Platform with: + +- **Per-assembly log groups**: emits `::group::` / `::endgroup::` workflow commands so each test assembly's output is collapsed by default in the runner UI +- **Failure annotations**: emits an `::error` workflow command for each failing test so failures appear in the workflow Annotations tab and, when the source location can be resolved, on the pull request's "Files changed" diff gutter +- **Job summary**: appends a markdown roll-up (totals, failures, slowest tests) to the file pointed to by `GITHUB_STEP_SUMMARY`, which GitHub renders on the workflow run summary page +- **Slow-test notices**: emits a `::notice` workflow command for any test still running past a threshold (default 60 seconds) +- **CI auto-detection**: detects GitHub Actions environments through the `GITHUB_ACTIONS` variable + +All features are enabled by default when running on GitHub Actions (or when the `--report-gh` master switch is set), and no-op otherwise. Each feature can be toggled individually: + +| Option | Description | Default | +|---|---|---| +| `--report-gh` | Master switch to enable the extension outside GitHub Actions | off | +| `--report-gh-groups on\|off` | Per-assembly log groups | on | +| `--report-gh-annotations on\|off` | Failure annotations | on | +| `--report-gh-step-summary on\|off` | Markdown job summary | on | +| `--report-gh-slow-test-notices on\|off` | Slow-test notices | on | +| `--report-gh-slow-test-threshold ` | Seconds before a slow-test notice is emitted | 60 | + +## Related packages + +- [Microsoft.Testing.Extensions.AzureDevOpsReport](https://www.nuget.org/packages/Microsoft.Testing.Extensions.AzureDevOpsReport): Azure DevOps reporting + +## Documentation + +For comprehensive documentation, see . + +## Feedback & contributing + +Microsoft.Testing.Platform is an open source project. Provide feedback or report issues in the [microsoft/testfx](https://github.com/microsoft/testfx/issues) GitHub repository. diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/PublicAPI/PublicAPI.Shipped.txt b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/PublicAPI/PublicAPI.Shipped.txt new file mode 100644 index 0000000000..7dc5c58110 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/PublicAPI/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/PublicAPI/PublicAPI.Unshipped.txt b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/PublicAPI/PublicAPI.Unshipped.txt new file mode 100644 index 0000000000..0a19cd14d6 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/PublicAPI/PublicAPI.Unshipped.txt @@ -0,0 +1,5 @@ +#nullable enable +Microsoft.Testing.Extensions.GitHubActionsExtensions +Microsoft.Testing.Extensions.GitHubActionsReport.TestingPlatformBuilderHook +static Microsoft.Testing.Extensions.GitHubActionsExtensions.AddGitHubActionsProvider(this Microsoft.Testing.Platform.Builder.ITestApplicationBuilder! builder) -> void +static Microsoft.Testing.Extensions.GitHubActionsReport.TestingPlatformBuilderHook.AddExtensions(Microsoft.Testing.Platform.Builder.ITestApplicationBuilder! testApplicationBuilder, string![]! _) -> void diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/GitHubActionsResources.resx b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/GitHubActionsResources.resx new file mode 100644 index 0000000000..579a7f0f30 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/GitHubActionsResources.resx @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + + + Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + {Locked="on"}{Locked="off"} + + + Test failed: {0} + {0} is the fully qualified test name. + + + The test failed without providing a failure message. + + + Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + {Locked="on"}{Locked="off"}{Locked="GITHUB_STEP_SUMMARY"} + + + Failed to write the GitHub Actions job summary to '{0}': {1} + {0} is the file path, {1} is the error message. + + + Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + {Locked="on"}{Locked="off"} + + + The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. + + + Slow test: {0} + {0} is the fully qualified test name. + + + {0} still running after {1}s + {0} is the fully qualified test name, {1} is the elapsed seconds. + + + Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. + + + GitHub Actions report generator + + + Tests: {0} ({1}) + {0} is the assembly name, {1} is the target framework moniker. + + + Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + {Locked="on"}{Locked="off"} + + + Invalid value '{0}'. Valid values are 'on' and 'off'. + {Locked="on"}{Locked="off"} + + + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + + diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.cs.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.cs.xlf new file mode 100644 index 0000000000..be2fbed7c2 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.cs.xlf @@ -0,0 +1,87 @@ + + + + + + Test failed: {0} + Test failed: {0} + {0} is the fully qualified test name. + + + Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + {Locked="on"}{Locked="off"} + + + GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + + + + GitHub Actions report generator + GitHub Actions report generator + + + + Tests: {0} ({1}) + Tests: {0} ({1}) + {0} is the assembly name, {1} is the target framework moniker. + + + Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + {Locked="on"}{Locked="off"} + + + Invalid value '{0}'. Valid values are 'on' and 'off'. + Invalid value '{0}'. Valid values are 'on' and 'off'. + {Locked="on"}{Locked="off"} + + + Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. + Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. + + + + The test failed without providing a failure message. + The test failed without providing a failure message. + + + + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + + + + Slow test: {0} + Slow test: {0} + {0} is the fully qualified test name. + + + Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + {Locked="on"}{Locked="off"} + + + {0} still running after {1}s + {0} still running after {1}s + {0} is the fully qualified test name, {1} is the elapsed seconds. + + + The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. + The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. + + + + Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + {Locked="on"}{Locked="off"}{Locked="GITHUB_STEP_SUMMARY"} + + + Failed to write the GitHub Actions job summary to '{0}': {1} + Failed to write the GitHub Actions job summary to '{0}': {1} + {0} is the file path, {1} is the error message. + + + + \ No newline at end of file diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.de.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.de.xlf new file mode 100644 index 0000000000..af245b124f --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.de.xlf @@ -0,0 +1,87 @@ + + + + + + Test failed: {0} + Test failed: {0} + {0} is the fully qualified test name. + + + Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + {Locked="on"}{Locked="off"} + + + GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + + + + GitHub Actions report generator + GitHub Actions report generator + + + + Tests: {0} ({1}) + Tests: {0} ({1}) + {0} is the assembly name, {1} is the target framework moniker. + + + Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + {Locked="on"}{Locked="off"} + + + Invalid value '{0}'. Valid values are 'on' and 'off'. + Invalid value '{0}'. Valid values are 'on' and 'off'. + {Locked="on"}{Locked="off"} + + + Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. + Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. + + + + The test failed without providing a failure message. + The test failed without providing a failure message. + + + + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + + + + Slow test: {0} + Slow test: {0} + {0} is the fully qualified test name. + + + Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + {Locked="on"}{Locked="off"} + + + {0} still running after {1}s + {0} still running after {1}s + {0} is the fully qualified test name, {1} is the elapsed seconds. + + + The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. + The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. + + + + Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + {Locked="on"}{Locked="off"}{Locked="GITHUB_STEP_SUMMARY"} + + + Failed to write the GitHub Actions job summary to '{0}': {1} + Failed to write the GitHub Actions job summary to '{0}': {1} + {0} is the file path, {1} is the error message. + + + + \ No newline at end of file diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.es.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.es.xlf new file mode 100644 index 0000000000..c0ef507eb8 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.es.xlf @@ -0,0 +1,87 @@ + + + + + + Test failed: {0} + Test failed: {0} + {0} is the fully qualified test name. + + + Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + {Locked="on"}{Locked="off"} + + + GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + + + + GitHub Actions report generator + GitHub Actions report generator + + + + Tests: {0} ({1}) + Tests: {0} ({1}) + {0} is the assembly name, {1} is the target framework moniker. + + + Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + {Locked="on"}{Locked="off"} + + + Invalid value '{0}'. Valid values are 'on' and 'off'. + Invalid value '{0}'. Valid values are 'on' and 'off'. + {Locked="on"}{Locked="off"} + + + Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. + Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. + + + + The test failed without providing a failure message. + The test failed without providing a failure message. + + + + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + + + + Slow test: {0} + Slow test: {0} + {0} is the fully qualified test name. + + + Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + {Locked="on"}{Locked="off"} + + + {0} still running after {1}s + {0} still running after {1}s + {0} is the fully qualified test name, {1} is the elapsed seconds. + + + The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. + The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. + + + + Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + {Locked="on"}{Locked="off"}{Locked="GITHUB_STEP_SUMMARY"} + + + Failed to write the GitHub Actions job summary to '{0}': {1} + Failed to write the GitHub Actions job summary to '{0}': {1} + {0} is the file path, {1} is the error message. + + + + \ No newline at end of file diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.fr.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.fr.xlf new file mode 100644 index 0000000000..2dfbebbae0 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.fr.xlf @@ -0,0 +1,87 @@ + + + + + + Test failed: {0} + Test failed: {0} + {0} is the fully qualified test name. + + + Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + {Locked="on"}{Locked="off"} + + + GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + + + + GitHub Actions report generator + GitHub Actions report generator + + + + Tests: {0} ({1}) + Tests: {0} ({1}) + {0} is the assembly name, {1} is the target framework moniker. + + + Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + {Locked="on"}{Locked="off"} + + + Invalid value '{0}'. Valid values are 'on' and 'off'. + Invalid value '{0}'. Valid values are 'on' and 'off'. + {Locked="on"}{Locked="off"} + + + Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. + Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. + + + + The test failed without providing a failure message. + The test failed without providing a failure message. + + + + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + + + + Slow test: {0} + Slow test: {0} + {0} is the fully qualified test name. + + + Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + {Locked="on"}{Locked="off"} + + + {0} still running after {1}s + {0} still running after {1}s + {0} is the fully qualified test name, {1} is the elapsed seconds. + + + The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. + The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. + + + + Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + {Locked="on"}{Locked="off"}{Locked="GITHUB_STEP_SUMMARY"} + + + Failed to write the GitHub Actions job summary to '{0}': {1} + Failed to write the GitHub Actions job summary to '{0}': {1} + {0} is the file path, {1} is the error message. + + + + \ No newline at end of file diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.it.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.it.xlf new file mode 100644 index 0000000000..5820a68215 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.it.xlf @@ -0,0 +1,87 @@ + + + + + + Test failed: {0} + Test failed: {0} + {0} is the fully qualified test name. + + + Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + {Locked="on"}{Locked="off"} + + + GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + + + + GitHub Actions report generator + GitHub Actions report generator + + + + Tests: {0} ({1}) + Tests: {0} ({1}) + {0} is the assembly name, {1} is the target framework moniker. + + + Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + {Locked="on"}{Locked="off"} + + + Invalid value '{0}'. Valid values are 'on' and 'off'. + Invalid value '{0}'. Valid values are 'on' and 'off'. + {Locked="on"}{Locked="off"} + + + Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. + Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. + + + + The test failed without providing a failure message. + The test failed without providing a failure message. + + + + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + + + + Slow test: {0} + Slow test: {0} + {0} is the fully qualified test name. + + + Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + {Locked="on"}{Locked="off"} + + + {0} still running after {1}s + {0} still running after {1}s + {0} is the fully qualified test name, {1} is the elapsed seconds. + + + The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. + The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. + + + + Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + {Locked="on"}{Locked="off"}{Locked="GITHUB_STEP_SUMMARY"} + + + Failed to write the GitHub Actions job summary to '{0}': {1} + Failed to write the GitHub Actions job summary to '{0}': {1} + {0} is the file path, {1} is the error message. + + + + \ No newline at end of file diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ja.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ja.xlf new file mode 100644 index 0000000000..f57965206a --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ja.xlf @@ -0,0 +1,87 @@ + + + + + + Test failed: {0} + Test failed: {0} + {0} is the fully qualified test name. + + + Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + {Locked="on"}{Locked="off"} + + + GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + + + + GitHub Actions report generator + GitHub Actions report generator + + + + Tests: {0} ({1}) + Tests: {0} ({1}) + {0} is the assembly name, {1} is the target framework moniker. + + + Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + {Locked="on"}{Locked="off"} + + + Invalid value '{0}'. Valid values are 'on' and 'off'. + Invalid value '{0}'. Valid values are 'on' and 'off'. + {Locked="on"}{Locked="off"} + + + Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. + Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. + + + + The test failed without providing a failure message. + The test failed without providing a failure message. + + + + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + + + + Slow test: {0} + Slow test: {0} + {0} is the fully qualified test name. + + + Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + {Locked="on"}{Locked="off"} + + + {0} still running after {1}s + {0} still running after {1}s + {0} is the fully qualified test name, {1} is the elapsed seconds. + + + The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. + The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. + + + + Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + {Locked="on"}{Locked="off"}{Locked="GITHUB_STEP_SUMMARY"} + + + Failed to write the GitHub Actions job summary to '{0}': {1} + Failed to write the GitHub Actions job summary to '{0}': {1} + {0} is the file path, {1} is the error message. + + + + \ No newline at end of file diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ko.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ko.xlf new file mode 100644 index 0000000000..2cdaa3623f --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ko.xlf @@ -0,0 +1,87 @@ + + + + + + Test failed: {0} + Test failed: {0} + {0} is the fully qualified test name. + + + Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + {Locked="on"}{Locked="off"} + + + GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + + + + GitHub Actions report generator + GitHub Actions report generator + + + + Tests: {0} ({1}) + Tests: {0} ({1}) + {0} is the assembly name, {1} is the target framework moniker. + + + Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + {Locked="on"}{Locked="off"} + + + Invalid value '{0}'. Valid values are 'on' and 'off'. + Invalid value '{0}'. Valid values are 'on' and 'off'. + {Locked="on"}{Locked="off"} + + + Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. + Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. + + + + The test failed without providing a failure message. + The test failed without providing a failure message. + + + + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + + + + Slow test: {0} + Slow test: {0} + {0} is the fully qualified test name. + + + Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + {Locked="on"}{Locked="off"} + + + {0} still running after {1}s + {0} still running after {1}s + {0} is the fully qualified test name, {1} is the elapsed seconds. + + + The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. + The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. + + + + Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + {Locked="on"}{Locked="off"}{Locked="GITHUB_STEP_SUMMARY"} + + + Failed to write the GitHub Actions job summary to '{0}': {1} + Failed to write the GitHub Actions job summary to '{0}': {1} + {0} is the file path, {1} is the error message. + + + + \ No newline at end of file diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.pl.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.pl.xlf new file mode 100644 index 0000000000..7e4e3c639b --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.pl.xlf @@ -0,0 +1,87 @@ + + + + + + Test failed: {0} + Test failed: {0} + {0} is the fully qualified test name. + + + Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + {Locked="on"}{Locked="off"} + + + GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + + + + GitHub Actions report generator + GitHub Actions report generator + + + + Tests: {0} ({1}) + Tests: {0} ({1}) + {0} is the assembly name, {1} is the target framework moniker. + + + Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + {Locked="on"}{Locked="off"} + + + Invalid value '{0}'. Valid values are 'on' and 'off'. + Invalid value '{0}'. Valid values are 'on' and 'off'. + {Locked="on"}{Locked="off"} + + + Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. + Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. + + + + The test failed without providing a failure message. + The test failed without providing a failure message. + + + + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + + + + Slow test: {0} + Slow test: {0} + {0} is the fully qualified test name. + + + Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + {Locked="on"}{Locked="off"} + + + {0} still running after {1}s + {0} still running after {1}s + {0} is the fully qualified test name, {1} is the elapsed seconds. + + + The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. + The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. + + + + Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + {Locked="on"}{Locked="off"}{Locked="GITHUB_STEP_SUMMARY"} + + + Failed to write the GitHub Actions job summary to '{0}': {1} + Failed to write the GitHub Actions job summary to '{0}': {1} + {0} is the file path, {1} is the error message. + + + + \ No newline at end of file diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.pt-BR.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.pt-BR.xlf new file mode 100644 index 0000000000..45497c6b59 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.pt-BR.xlf @@ -0,0 +1,87 @@ + + + + + + Test failed: {0} + Test failed: {0} + {0} is the fully qualified test name. + + + Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + {Locked="on"}{Locked="off"} + + + GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + + + + GitHub Actions report generator + GitHub Actions report generator + + + + Tests: {0} ({1}) + Tests: {0} ({1}) + {0} is the assembly name, {1} is the target framework moniker. + + + Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + {Locked="on"}{Locked="off"} + + + Invalid value '{0}'. Valid values are 'on' and 'off'. + Invalid value '{0}'. Valid values are 'on' and 'off'. + {Locked="on"}{Locked="off"} + + + Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. + Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. + + + + The test failed without providing a failure message. + The test failed without providing a failure message. + + + + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + + + + Slow test: {0} + Slow test: {0} + {0} is the fully qualified test name. + + + Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + {Locked="on"}{Locked="off"} + + + {0} still running after {1}s + {0} still running after {1}s + {0} is the fully qualified test name, {1} is the elapsed seconds. + + + The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. + The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. + + + + Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + {Locked="on"}{Locked="off"}{Locked="GITHUB_STEP_SUMMARY"} + + + Failed to write the GitHub Actions job summary to '{0}': {1} + Failed to write the GitHub Actions job summary to '{0}': {1} + {0} is the file path, {1} is the error message. + + + + \ No newline at end of file diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ru.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ru.xlf new file mode 100644 index 0000000000..e40f505e56 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ru.xlf @@ -0,0 +1,87 @@ + + + + + + Test failed: {0} + Test failed: {0} + {0} is the fully qualified test name. + + + Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + {Locked="on"}{Locked="off"} + + + GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + + + + GitHub Actions report generator + GitHub Actions report generator + + + + Tests: {0} ({1}) + Tests: {0} ({1}) + {0} is the assembly name, {1} is the target framework moniker. + + + Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + {Locked="on"}{Locked="off"} + + + Invalid value '{0}'. Valid values are 'on' and 'off'. + Invalid value '{0}'. Valid values are 'on' and 'off'. + {Locked="on"}{Locked="off"} + + + Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. + Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. + + + + The test failed without providing a failure message. + The test failed without providing a failure message. + + + + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + + + + Slow test: {0} + Slow test: {0} + {0} is the fully qualified test name. + + + Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + {Locked="on"}{Locked="off"} + + + {0} still running after {1}s + {0} still running after {1}s + {0} is the fully qualified test name, {1} is the elapsed seconds. + + + The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. + The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. + + + + Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + {Locked="on"}{Locked="off"}{Locked="GITHUB_STEP_SUMMARY"} + + + Failed to write the GitHub Actions job summary to '{0}': {1} + Failed to write the GitHub Actions job summary to '{0}': {1} + {0} is the file path, {1} is the error message. + + + + \ No newline at end of file diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.tr.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.tr.xlf new file mode 100644 index 0000000000..9e0630d207 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.tr.xlf @@ -0,0 +1,87 @@ + + + + + + Test failed: {0} + Test failed: {0} + {0} is the fully qualified test name. + + + Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + {Locked="on"}{Locked="off"} + + + GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + + + + GitHub Actions report generator + GitHub Actions report generator + + + + Tests: {0} ({1}) + Tests: {0} ({1}) + {0} is the assembly name, {1} is the target framework moniker. + + + Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + {Locked="on"}{Locked="off"} + + + Invalid value '{0}'. Valid values are 'on' and 'off'. + Invalid value '{0}'. Valid values are 'on' and 'off'. + {Locked="on"}{Locked="off"} + + + Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. + Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. + + + + The test failed without providing a failure message. + The test failed without providing a failure message. + + + + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + + + + Slow test: {0} + Slow test: {0} + {0} is the fully qualified test name. + + + Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + {Locked="on"}{Locked="off"} + + + {0} still running after {1}s + {0} still running after {1}s + {0} is the fully qualified test name, {1} is the elapsed seconds. + + + The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. + The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. + + + + Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + {Locked="on"}{Locked="off"}{Locked="GITHUB_STEP_SUMMARY"} + + + Failed to write the GitHub Actions job summary to '{0}': {1} + Failed to write the GitHub Actions job summary to '{0}': {1} + {0} is the file path, {1} is the error message. + + + + \ No newline at end of file diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.zh-Hans.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.zh-Hans.xlf new file mode 100644 index 0000000000..e1156de0ef --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.zh-Hans.xlf @@ -0,0 +1,87 @@ + + + + + + Test failed: {0} + Test failed: {0} + {0} is the fully qualified test name. + + + Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + {Locked="on"}{Locked="off"} + + + GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + + + + GitHub Actions report generator + GitHub Actions report generator + + + + Tests: {0} ({1}) + Tests: {0} ({1}) + {0} is the assembly name, {1} is the target framework moniker. + + + Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + {Locked="on"}{Locked="off"} + + + Invalid value '{0}'. Valid values are 'on' and 'off'. + Invalid value '{0}'. Valid values are 'on' and 'off'. + {Locked="on"}{Locked="off"} + + + Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. + Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. + + + + The test failed without providing a failure message. + The test failed without providing a failure message. + + + + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + + + + Slow test: {0} + Slow test: {0} + {0} is the fully qualified test name. + + + Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + {Locked="on"}{Locked="off"} + + + {0} still running after {1}s + {0} still running after {1}s + {0} is the fully qualified test name, {1} is the elapsed seconds. + + + The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. + The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. + + + + Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + {Locked="on"}{Locked="off"}{Locked="GITHUB_STEP_SUMMARY"} + + + Failed to write the GitHub Actions job summary to '{0}': {1} + Failed to write the GitHub Actions job summary to '{0}': {1} + {0} is the file path, {1} is the error message. + + + + \ No newline at end of file diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.zh-Hant.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.zh-Hant.xlf new file mode 100644 index 0000000000..7f7a144bb7 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.zh-Hant.xlf @@ -0,0 +1,87 @@ + + + + + + Test failed: {0} + Test failed: {0} + {0} is the fully qualified test name. + + + Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + Enable or disable GitHub Actions failure annotations ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + {Locked="on"}{Locked="off"} + + + GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + + + + GitHub Actions report generator + GitHub Actions report generator + + + + Tests: {0} ({1}) + Tests: {0} ({1}) + {0} is the assembly name, {1} is the target framework moniker. + + + Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + Enable or disable per-assembly log groups ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + {Locked="on"}{Locked="off"} + + + Invalid value '{0}'. Valid values are 'on' and 'off'. + Invalid value '{0}'. Valid values are 'on' and 'off'. + {Locked="on"}{Locked="off"} + + + Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. + Invalid value '{0}'. The slow-test threshold must be a positive integer number of seconds. + + + + The test failed without providing a failure message. + The test failed without providing a failure message. + + + + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + Enable GitHub Actions report generator to emit workflow commands so test runs produce a first-class experience on GitHub Actions. + + + + Slow test: {0} + Slow test: {0} + {0} is the fully qualified test name. + + + Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + Enable or disable GitHub Actions slow-test notices ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + {Locked="on"}{Locked="off"} + + + {0} still running after {1}s + {0} still running after {1}s + {0} is the fully qualified test name, {1} is the elapsed seconds. + + + The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. + The number of seconds a test may run before a GitHub Actions slow-test notice is emitted. Defaults to 60. + + + + Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + Enable or disable writing a markdown job summary to the GITHUB_STEP_SUMMARY file ('on' or 'off'). Defaults to 'on' when running on GitHub Actions. + {Locked="on"}{Locked="off"}{Locked="GITHUB_STEP_SUMMARY"} + + + Failed to write the GitHub Actions job summary to '{0}': {1} + Failed to write the GitHub Actions job summary to '{0}': {1} + {0} is the file path, {1} is the error message. + + + + \ No newline at end of file diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/TargetFrameworkMonikerHelper.cs b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/TargetFrameworkMonikerHelper.cs new file mode 100644 index 0000000000..1de5dbc640 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/TargetFrameworkMonikerHelper.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.Testing.Platform.OutputDevice; + +namespace Microsoft.Testing.Extensions.GitHubActionsReport; + +internal static class TargetFrameworkMonikerHelper +{ + public static string GetTargetFrameworkMoniker() + => TargetFrameworkParser.GetShortTargetFramework(Assembly.GetEntryAssembly()?.GetCustomAttribute()?.FrameworkDisplayName) + ?? TargetFrameworkParser.GetShortTargetFramework(System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription) + ?? "unknown"; +} diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/TestNodeIdentity.cs b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/TestNodeIdentity.cs new file mode 100644 index 0000000000..f6d1cd53ec --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/TestNodeIdentity.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.Testing.Platform.Extensions.Messages; + +namespace Microsoft.Testing.Extensions.GitHubActionsReport; + +internal static class TestNodeIdentity +{ + private const string FullyQualifiedNamePropertyKey = "vstest.TestCase.FullyQualifiedName"; + + /// + /// Resolves the stable, fully-qualified test name. Falls back to the display name when the + /// fully-qualified name property is unavailable. + /// + public static string GetTestName(TestNode testNode) + { + using PropertyBag.PropertyBagEnumerator enumerator = testNode.Properties.GetStructEnumerator(); + while (enumerator.MoveNext()) + { + if (enumerator.Current is SerializableKeyValuePairStringProperty kvp + && kvp.Key == FullyQualifiedNamePropertyKey) + { + return kvp.Value; + } + } + + return testNode.DisplayName; + } +} diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/TestingPlatformBuilderHook.cs b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/TestingPlatformBuilderHook.cs new file mode 100644 index 0000000000..849231f52a --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/TestingPlatformBuilderHook.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.Testing.Platform.Builder; + +namespace Microsoft.Testing.Extensions.GitHubActionsReport; + +/// +/// This class is used by Microsoft.Testing.Platform.MSBuild to hook into the Testing Platform Builder to add GitHub Actions reporting support. +/// +public static class TestingPlatformBuilderHook +{ + /// + /// Adds GitHub Actions reporting support to the Testing Platform Builder. + /// + /// The test application builder. + /// The command line arguments. + public static void AddExtensions(ITestApplicationBuilder testApplicationBuilder, string[] _) + => testApplicationBuilder.AddGitHubActionsProvider(); +} diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/build/Microsoft.Testing.Extensions.GitHubActionsReport.props b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/build/Microsoft.Testing.Extensions.GitHubActionsReport.props new file mode 100644 index 0000000000..b640aa2cd6 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/build/Microsoft.Testing.Extensions.GitHubActionsReport.props @@ -0,0 +1,3 @@ + + + diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/buildMultiTargeting/Microsoft.Testing.Extensions.GitHubActionsReport.props b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/buildMultiTargeting/Microsoft.Testing.Extensions.GitHubActionsReport.props new file mode 100644 index 0000000000..ea74c713e0 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/buildMultiTargeting/Microsoft.Testing.Extensions.GitHubActionsReport.props @@ -0,0 +1,9 @@ + + + + + Microsoft.Testing.Extensions.GitHubActionsReport + Microsoft.Testing.Extensions.GitHubActionsReport.TestingPlatformBuilderHook + + + diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/buildTransitive/Microsoft.Testing.Extensions.GitHubActionsReport.props b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/buildTransitive/Microsoft.Testing.Extensions.GitHubActionsReport.props new file mode 100644 index 0000000000..b640aa2cd6 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/buildTransitive/Microsoft.Testing.Extensions.GitHubActionsReport.props @@ -0,0 +1,3 @@ + + + diff --git a/src/Platform/Microsoft.Testing.Platform/Microsoft.Testing.Platform.csproj b/src/Platform/Microsoft.Testing.Platform/Microsoft.Testing.Platform.csproj index 593447ec6c..3fd6572bc4 100644 --- a/src/Platform/Microsoft.Testing.Platform/Microsoft.Testing.Platform.csproj +++ b/src/Platform/Microsoft.Testing.Platform/Microsoft.Testing.Platform.csproj @@ -48,6 +48,7 @@ This package provides the core platform and the .NET implementation of the proto + diff --git a/src/TestFramework/TestFramework.Extensions/TestFramework.Extensions.csproj b/src/TestFramework/TestFramework.Extensions/TestFramework.Extensions.csproj index d5d61fd216..ad1a949241 100644 --- a/src/TestFramework/TestFramework.Extensions/TestFramework.Extensions.csproj +++ b/src/TestFramework/TestFramework.Extensions/TestFramework.Extensions.csproj @@ -1,4 +1,4 @@ - + @@ -11,12 +11,15 @@ true +<<<<<<< HEAD false +======= +>>>>>>> Add Microsoft.Testing.Extensions.GitHubActionsReport with per-assembly log groups MSTest.TestFramework MSTest TestFramework Unittest MSTestV2 Microsoft Test Testing TDD Framework @@ -31,7 +34,14 @@ - UWP 10.0.16299 - UWP 10.0.17763 with .NET 9+ +<<<<<<< HEAD $(NoWarn);NU5100;NU5128 +======= + PACKAGE.md + $(NoWarn);NU5100 + + false +>>>>>>> Add Microsoft.Testing.Extensions.GitHubActionsReport with per-assembly log groups @@ -90,6 +100,22 @@ + + + + + + + + + + + @@ -108,6 +134,7 @@ +<<<<<<< HEAD + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +>>>>>>> Add Microsoft.Testing.Extensions.GitHubActionsReport with per-assembly log groups diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsAnnotationReporterTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsAnnotationReporterTests.cs new file mode 100644 index 0000000000..9e56d1bc23 --- /dev/null +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsAnnotationReporterTests.cs @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.Testing.Extensions.GitHubActionsReport; +using Microsoft.Testing.Platform.Helpers; +using Microsoft.Testing.Platform.Logging; + +namespace Microsoft.Testing.Extensions.UnitTests; + +[TestClass] +public sealed class GitHubActionsAnnotationReporterTests +{ + [TestMethod] + public void GetErrorAnnotation_ReportsFirstExistingFileWithLineColTitleAndEscaping() + { + Exception error; + try + { + throw new Exception("this is an error\nwith\rnewline"); + } + catch (Exception ex) + { + error = ex; + } + + var logger = new NoopLogger(); + string text = GitHubActionsAnnotationReporter.GetErrorAnnotation("MyNamespace.MyTest", null, error, GitHubActionsRepositoryRoot.FindGitRoot(), new SystemFileSystem(), logger); + + Assert.AreEqual( + "::error file=test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsAnnotationReporterTests.cs,line=19,col=1,title=Test failed%3A MyNamespace.MyTest::this is an error%0Awith%0Dnewline", + text); + } + + [TestMethod] + public void GetErrorAnnotation_PrefersExplanationOverExceptionMessage() + { + Exception error; + try + { + throw new Exception("exception message"); + } + catch (Exception ex) + { + error = ex; + } + + var logger = new NoopLogger(); + string text = GitHubActionsAnnotationReporter.GetErrorAnnotation("MyNamespace.MyTest", "Some custom reason\nwith\rnewline", error, GitHubActionsRepositoryRoot.FindGitRoot(), new SystemFileSystem(), logger); + + Assert.AreEqual( + "::error file=test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsAnnotationReporterTests.cs,line=40,col=1,title=Test failed%3A MyNamespace.MyTest::Some custom reason%0Awith%0Dnewline", + text); + } + + [TestMethod] + public void GetErrorAnnotation_FallsBackToTitleOnly_WhenNoSourceLocation() + { + var logger = new NoopLogger(); + string text = GitHubActionsAnnotationReporter.GetErrorAnnotation("MyNamespace.MyTest", "boom", exception: null, repoRoot: null, new SystemFileSystem(), logger); + + Assert.AreEqual("::error title=Test failed%3A MyNamespace.MyTest::boom", text); + } + + [TestMethod] + public void GetErrorAnnotation_UsesFallbackMessage_WhenNoExplanationOrException() + { + var logger = new NoopLogger(); + string text = GitHubActionsAnnotationReporter.GetErrorAnnotation("MyNamespace.MyTest", explanation: null, exception: null, repoRoot: null, new SystemFileSystem(), logger); + + Assert.IsTrue(text.StartsWith("::error title=Test failed%3A MyNamespace.MyTest::", StringComparison.Ordinal), text); + } + + private sealed class NoopLogger : ILogger + { + public bool IsEnabled(LogLevel logLevel) => false; + + public void Log(LogLevel logLevel, TState state, Exception? exception, Func formatter) + { + } + + public Task LogAsync(LogLevel logLevel, TState state, Exception? exception, Func formatter) + => Task.CompletedTask; + } +} diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsCommandLineProviderTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsCommandLineProviderTests.cs new file mode 100644 index 0000000000..d9de66dffc --- /dev/null +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsCommandLineProviderTests.cs @@ -0,0 +1,92 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.Testing.Extensions.GitHubActionsReport; +using Microsoft.Testing.Platform.CommandLine; +using Microsoft.Testing.Platform.Extensions.CommandLine; + +namespace Microsoft.Testing.Extensions.UnitTests; + +[TestClass] +public sealed class GitHubActionsCommandLineProviderTests +{ + [TestMethod] + public async Task ValidateOptionArgumentsAsync_ReturnsInvalid_WhenGroupsValueIsNotOnOrOffAsync() + { + GitHubActionsCommandLineProvider provider = new(); + CommandLineOption option = provider.GetCommandLineOptions().Single(o => o.Name == GitHubActionsCommandLineOptions.GitHubActionsGroups); + ValidationResult validationResult = await provider.ValidateOptionArgumentsAsync(option, ["maybe"]).ConfigureAwait(false); + + Assert.IsFalse(validationResult.IsValid); + } + + [TestMethod] + public async Task ValidateOptionArgumentsAsync_ReturnsValid_WhenGroupsValueIsOffAsync() + { + GitHubActionsCommandLineProvider provider = new(); + CommandLineOption option = provider.GetCommandLineOptions().Single(o => o.Name == GitHubActionsCommandLineOptions.GitHubActionsGroups); + ValidationResult validationResult = await provider.ValidateOptionArgumentsAsync(option, ["off"]).ConfigureAwait(false); + + Assert.IsTrue(validationResult.IsValid); + } + + [TestMethod] + public async Task ValidateOptionArgumentsAsync_ReturnsInvalid_WhenAnnotationsValueIsNotOnOrOffAsync() + { + GitHubActionsCommandLineProvider provider = new(); + CommandLineOption option = provider.GetCommandLineOptions().Single(o => o.Name == GitHubActionsCommandLineOptions.GitHubActionsAnnotations); + ValidationResult validationResult = await provider.ValidateOptionArgumentsAsync(option, ["maybe"]).ConfigureAwait(false); + + Assert.IsFalse(validationResult.IsValid); + } + + [TestMethod] + public async Task ValidateOptionArgumentsAsync_ReturnsValid_WhenAnnotationsValueIsOffAsync() + { + GitHubActionsCommandLineProvider provider = new(); + CommandLineOption option = provider.GetCommandLineOptions().Single(o => o.Name == GitHubActionsCommandLineOptions.GitHubActionsAnnotations); + ValidationResult validationResult = await provider.ValidateOptionArgumentsAsync(option, ["off"]).ConfigureAwait(false); + + Assert.IsTrue(validationResult.IsValid); + } + + [TestMethod] + public async Task ValidateOptionArgumentsAsync_ReturnsInvalid_WhenStepSummaryValueIsNotOnOrOffAsync() + { + GitHubActionsCommandLineProvider provider = new(); + CommandLineOption option = provider.GetCommandLineOptions().Single(o => o.Name == GitHubActionsCommandLineOptions.GitHubActionsStepSummary); + ValidationResult validationResult = await provider.ValidateOptionArgumentsAsync(option, ["maybe"]).ConfigureAwait(false); + + Assert.IsFalse(validationResult.IsValid); + } + + [TestMethod] + public async Task ValidateOptionArgumentsAsync_ReturnsInvalid_WhenSlowTestNoticesValueIsNotOnOrOffAsync() + { + GitHubActionsCommandLineProvider provider = new(); + CommandLineOption option = provider.GetCommandLineOptions().Single(o => o.Name == GitHubActionsCommandLineOptions.GitHubActionsSlowTestNotices); + ValidationResult validationResult = await provider.ValidateOptionArgumentsAsync(option, ["maybe"]).ConfigureAwait(false); + + Assert.IsFalse(validationResult.IsValid); + } + + [TestMethod] + public async Task ValidateOptionArgumentsAsync_ReturnsInvalid_WhenSlowTestThresholdIsNotPositiveIntegerAsync() + { + GitHubActionsCommandLineProvider provider = new(); + CommandLineOption option = provider.GetCommandLineOptions().Single(o => o.Name == GitHubActionsCommandLineOptions.GitHubActionsSlowTestThreshold); + ValidationResult validationResult = await provider.ValidateOptionArgumentsAsync(option, ["0"]).ConfigureAwait(false); + + Assert.IsFalse(validationResult.IsValid); + } + + [TestMethod] + public async Task ValidateOptionArgumentsAsync_ReturnsValid_WhenSlowTestThresholdIsPositiveIntegerAsync() + { + GitHubActionsCommandLineProvider provider = new(); + CommandLineOption option = provider.GetCommandLineOptions().Single(o => o.Name == GitHubActionsCommandLineOptions.GitHubActionsSlowTestThreshold); + ValidationResult validationResult = await provider.ValidateOptionArgumentsAsync(option, ["30"]).ConfigureAwait(false); + + Assert.IsTrue(validationResult.IsValid); + } +} diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsEscaperTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsEscaperTests.cs new file mode 100644 index 0000000000..a541050402 --- /dev/null +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsEscaperTests.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.Testing.Extensions.GitHubActionsReport; + +namespace Microsoft.Testing.Extensions.UnitTests; + +[TestClass] +public sealed class GitHubActionsEscaperTests +{ + [TestMethod] + public void EscapeData_EscapesPercentAndNewlines() + => Assert.AreEqual("a%25b%0Ac%0Dd", GitHubActionsEscaper.EscapeData("a%b\nc\rd")); + + [TestMethod] + public void EscapeData_LeavesPlainTextUntouched() + => Assert.AreEqual("Tests: MSTest.UnitTests (net9.0)", GitHubActionsEscaper.EscapeData("Tests: MSTest.UnitTests (net9.0)")); + + [TestMethod] + public void EscapeData_ReturnsEmptyForEmpty() + => Assert.AreEqual(string.Empty, GitHubActionsEscaper.EscapeData(string.Empty)); + + [TestMethod] + public void EscapeProperty_EscapesPercentNewlinesColonAndComma() + => Assert.AreEqual("a%25b%0Ac%0Dd%3Ae%2Cf", GitHubActionsEscaper.EscapeProperty("a%b\nc\rd:e,f")); + + [TestMethod] + public void EscapeProperty_LeavesPlainTextUntouched() + => Assert.AreEqual("Test failed", GitHubActionsEscaper.EscapeProperty("Test failed")); +} diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsReporterTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsReporterTests.cs new file mode 100644 index 0000000000..52c79330c2 --- /dev/null +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsReporterTests.cs @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.Testing.Extensions.GitHubActionsReport; +using Microsoft.Testing.Extensions.UnitTests.Helpers; +using Microsoft.Testing.Platform.Extensions.OutputDevice; +using Microsoft.Testing.Platform.Extensions.TestHost; +using Microsoft.Testing.Platform.Helpers; +using Microsoft.Testing.Platform.Logging; +using Microsoft.Testing.Platform.OutputDevice; +using Microsoft.Testing.Platform.Services; + +using Moq; + +namespace Microsoft.Testing.Extensions.UnitTests; + +[TestClass] +public sealed class GitHubActionsReporterTests +{ + [TestMethod] + public async Task IsEnabledAsync_ReturnsTrue_WhenRunningOnGitHubActionsAsync() + { + GitHubActionsReporter reporter = CreateReporter(githubActions: true, options: []); + Assert.IsTrue(await reporter.IsEnabledAsync().ConfigureAwait(false)); + } + + [TestMethod] + public async Task IsEnabledAsync_ReturnsFalse_WhenNotOnGitHubActionsAsync() + { + GitHubActionsReporter reporter = CreateReporter(githubActions: false, options: []); + Assert.IsFalse(await reporter.IsEnabledAsync().ConfigureAwait(false)); + } + + [TestMethod] + public async Task IsEnabledAsync_ReturnsFalse_WhenGroupsExplicitlyOffAsync() + { + GitHubActionsReporter reporter = CreateReporter(githubActions: true, options: new Dictionary + { + [GitHubActionsCommandLineOptions.GitHubActionsGroups] = ["off"], + }); + Assert.IsFalse(await reporter.IsEnabledAsync().ConfigureAwait(false)); + } + + [TestMethod] + public async Task SessionLifetime_EmitsGroupAndEndGroupAsync() + { + List output = []; + GitHubActionsReporter reporter = CreateReporter(githubActions: true, options: [], output, assemblyName: "MSTest.UnitTests"); + + var context = new Mock(); + context.SetupGet(c => c.CancellationToken).Returns(CancellationToken.None); + + await reporter.OnTestSessionStartingAsync(context.Object).ConfigureAwait(false); + await reporter.OnTestSessionFinishingAsync(context.Object).ConfigureAwait(false); + + Assert.HasCount(2, output); + Assert.IsTrue(output[0].StartsWith("::group::Tests: MSTest.UnitTests", StringComparison.Ordinal), output[0]); + Assert.AreEqual("::endgroup::", output[1]); + } + + private static GitHubActionsReporter CreateReporter(bool githubActions, Dictionary options, List? output = null, string assemblyName = "Some.UnitTests") + { + var environment = new Mock(); + environment.Setup(e => e.GetEnvironmentVariable("GITHUB_ACTIONS")).Returns(githubActions ? "true" : null); + + var outputDevice = new Mock(); + outputDevice + .Setup(o => o.DisplayAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((_, data, _) => output?.Add(((FormattedTextOutputDeviceData)data).Text)) + .Returns(Task.CompletedTask); + + var moduleInfo = new Mock(); + moduleInfo.Setup(m => m.TryGetAssemblyName()).Returns(assemblyName); + + var logger = new Mock(); + var loggerFactory = new Mock(); + loggerFactory.Setup(f => f.CreateLogger(It.IsAny())).Returns(logger.Object); + + return new GitHubActionsReporter( + new TestCommandLineOptions(options), + environment.Object, + outputDevice.Object, + moduleInfo.Object, + loggerFactory.Object); + } +} diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsSlowTestReporterTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsSlowTestReporterTests.cs new file mode 100644 index 0000000000..e55f229ae5 --- /dev/null +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsSlowTestReporterTests.cs @@ -0,0 +1,231 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Diagnostics.CodeAnalysis; + +using Microsoft.Testing.Extensions.GitHubActionsReport; +using Microsoft.Testing.Platform.CommandLine; +using Microsoft.Testing.Platform.Extensions.Messages; +using Microsoft.Testing.Platform.Extensions.OutputDevice; +using Microsoft.Testing.Platform.Helpers; +using Microsoft.Testing.Platform.Logging; +using Microsoft.Testing.Platform.OutputDevice; +using Microsoft.Testing.Platform.Services; +using Microsoft.Testing.Platform.TestHost; + +using Moq; + +namespace Microsoft.Testing.Extensions.UnitTests; + +[TestClass] +public sealed class GitHubActionsSlowTestReporterTests +{ + private static readonly DateTimeOffset Start = new(2025, 05, 16, 12, 00, 00, TimeSpan.Zero); + + [TestMethod] + public void BuildNoticeLine_FormatsNoticeWithEscapedTitleAndMessage() + { + string line = GitHubActionsSlowTestReporter.BuildNoticeLine("Ns.MyTest", TimeSpan.FromSeconds(75)); + + Assert.AreEqual("::notice title=Slow test%3A Ns.MyTest::Ns.MyTest still running after 75s", line); + } + + [TestMethod] + public async Task ScanOnce_AfterThreshold_EmitsSingleNoticeAsync() + { + CapturingOutputDevice outputDevice = new(); + GitHubActionsSlowTestReporter reporter = CreateReporter(outputDevice, githubActions: true); + await reporter.OnTestSessionStartingAsync(new TestSessionContextStub()).ConfigureAwait(false); + + await reporter.ConsumeAsync(null!, CreateMessage("u1", "Ns.T1", new InProgressTestNodeStateProperty()), CancellationToken.None).ConfigureAwait(false); + + // Default threshold is 60s; nothing before it. + await reporter.ScanOnceAsync(Start + TimeSpan.FromSeconds(30), CancellationToken.None).ConfigureAwait(false); + Assert.IsEmpty(outputDevice.Lines); + + await reporter.ScanOnceAsync(Start + TimeSpan.FromSeconds(90), CancellationToken.None).ConfigureAwait(false); + Assert.HasCount(1, outputDevice.Lines); + Assert.Contains("::notice", outputDevice.Lines[0]); + Assert.Contains("Ns.T1 still running after", outputDevice.Lines[0]); + } + + [TestMethod] + public async Task ScanOnce_UsesExponentialBackoffAsync() + { + CapturingOutputDevice outputDevice = new(); + GitHubActionsSlowTestReporter reporter = CreateReporter(outputDevice, githubActions: true); + await reporter.OnTestSessionStartingAsync(new TestSessionContextStub()).ConfigureAwait(false); + await reporter.ConsumeAsync(null!, CreateMessage("u1", "Ns.T1", new InProgressTestNodeStateProperty()), CancellationToken.None).ConfigureAwait(false); + + // First emit at >= 60s; threshold then doubles to 120s, so 90s does not emit again. + await reporter.ScanOnceAsync(Start + TimeSpan.FromSeconds(70), CancellationToken.None).ConfigureAwait(false); + await reporter.ScanOnceAsync(Start + TimeSpan.FromSeconds(90), CancellationToken.None).ConfigureAwait(false); + Assert.HasCount(1, outputDevice.Lines); + + await reporter.ScanOnceAsync(Start + TimeSpan.FromSeconds(130), CancellationToken.None).ConfigureAwait(false); + Assert.HasCount(2, outputDevice.Lines); + } + + [TestMethod] + public async Task ConsumeAsync_TerminalState_StopsTrackingAsync() + { + CapturingOutputDevice outputDevice = new(); + GitHubActionsSlowTestReporter reporter = CreateReporter(outputDevice, githubActions: true); + await reporter.OnTestSessionStartingAsync(new TestSessionContextStub()).ConfigureAwait(false); + + await reporter.ConsumeAsync(null!, CreateMessage("u1", "Ns.T1", new InProgressTestNodeStateProperty()), CancellationToken.None).ConfigureAwait(false); + await reporter.ConsumeAsync(null!, CreateMessage("u1", "Ns.T1", new PassedTestNodeStateProperty()), CancellationToken.None).ConfigureAwait(false); + + await reporter.ScanOnceAsync(Start + TimeSpan.FromSeconds(120), CancellationToken.None).ConfigureAwait(false); + Assert.IsEmpty(outputDevice.Lines); + } + + [TestMethod] + public async Task ScanOnce_WhenDisabled_DoesNotEmitAsync() + { + CapturingOutputDevice outputDevice = new(); + GitHubActionsSlowTestReporter reporter = CreateReporter(outputDevice, githubActions: false); + await reporter.OnTestSessionStartingAsync(new TestSessionContextStub()).ConfigureAwait(false); + + await reporter.ConsumeAsync(null!, CreateMessage("u1", "Ns.T1", new InProgressTestNodeStateProperty()), CancellationToken.None).ConfigureAwait(false); + await reporter.ScanOnceAsync(Start + TimeSpan.FromSeconds(120), CancellationToken.None).ConfigureAwait(false); + + Assert.IsEmpty(outputDevice.Lines); + } + + [TestMethod] + public async Task ScanOnce_RespectsConfiguredThresholdAsync() + { + CapturingOutputDevice outputDevice = new(); + Dictionary options = new(StringComparer.OrdinalIgnoreCase) + { + [GitHubActionsCommandLineOptions.GitHubActionsSlowTestThreshold] = ["2"], + }; + GitHubActionsSlowTestReporter reporter = CreateReporter(outputDevice, githubActions: true, options); + await reporter.OnTestSessionStartingAsync(new TestSessionContextStub()).ConfigureAwait(false); + await reporter.ConsumeAsync(null!, CreateMessage("u1", "Ns.T1", new InProgressTestNodeStateProperty()), CancellationToken.None).ConfigureAwait(false); + + await reporter.ScanOnceAsync(Start + TimeSpan.FromSeconds(1), CancellationToken.None).ConfigureAwait(false); + Assert.IsEmpty(outputDevice.Lines); + + await reporter.ScanOnceAsync(Start + TimeSpan.FromSeconds(3), CancellationToken.None).ConfigureAwait(false); + Assert.HasCount(1, outputDevice.Lines); + } + + private static GitHubActionsSlowTestReporter CreateReporter(CapturingOutputDevice outputDevice, bool githubActions, Dictionary? options = null) + { + Mock environmentMock = new(); + environmentMock.Setup(x => x.GetEnvironmentVariable("GITHUB_ACTIONS")).Returns(githubActions ? "true" : null); + + return new GitHubActionsSlowTestReporter( + new FakeCommandLineOptions(options ?? []), + environmentMock.Object, + outputDevice, + new NonRunningTask(), + new FixedClock(Start), + new StubLoggerFactory()); + } + + private static TestNodeUpdateMessage CreateMessage(string uid, string fullyQualifiedName, TestNodeStateProperty state) + { + PropertyBag propertyBag = new(); + propertyBag.Add(state); + propertyBag.Add(new SerializableKeyValuePairStringProperty("vstest.TestCase.FullyQualifiedName", fullyQualifiedName)); + + return new TestNodeUpdateMessage(new SessionUid("session"), new TestNode + { + Uid = uid, + DisplayName = fullyQualifiedName, + Properties = propertyBag, + }); + } + + private sealed class FakeCommandLineOptions(IReadOnlyDictionary options) : ICommandLineOptions + { + private readonly IReadOnlyDictionary _options = options; + + public bool IsOptionSet(string optionName) + => _options.ContainsKey(optionName); + + public bool TryGetOptionArgumentList(string optionName, [NotNullWhen(true)] out string[]? arguments) + { + if (_options.TryGetValue(optionName, out string[]? values)) + { + arguments = values; + return true; + } + + arguments = null; + return false; + } + } + + private sealed class CapturingOutputDevice : IOutputDevice + { + public List Lines { get; } = []; + + public Task DisplayAsync(IOutputDeviceDataProducer producer, IOutputDeviceData data, CancellationToken cancellationToken) + { + Lines.Add(((TextOutputDeviceData)data).Text); + return Task.CompletedTask; + } + } + + private sealed class FixedClock(DateTimeOffset now) : IClock + { + public DateTimeOffset UtcNow { get; } = now; + } + + private sealed class NonRunningTask : ITask + { + public Task Delay(int millisecondDelay) + => Task.CompletedTask; + + public Task Delay(TimeSpan timeSpan, CancellationToken cancellationToken) + => Task.CompletedTask; + + public Task Run(Action action) + { + action(); + return Task.CompletedTask; + } + + public Task Run(Func function, CancellationToken cancellationToken) + => function(); + + public Task Run(Func?> function, CancellationToken cancellationToken) + => function()!; + + public Task RunLongRunning(Func action, string name, CancellationToken cancellationToken) + => Task.CompletedTask; + + public Task WhenAll(params Task[] tasks) + => Task.WhenAll(tasks); + } + + private sealed class TestSessionContextStub : ITestSessionContext + { + public SessionUid SessionUid { get; } = new("session"); + + public CancellationToken CancellationToken { get; } = CancellationToken.None; + } + + private sealed class StubLoggerFactory : ILoggerFactory + { + public ILogger CreateLogger(string categoryName) + => new NullLogger(); + + private sealed class NullLogger : ILogger + { + public bool IsEnabled(LogLevel logLevel) + => false; + + public void Log(LogLevel logLevel, TState state, Exception? exception, Func formatter) + { + } + + public Task LogAsync(LogLevel logLevel, TState state, Exception? exception, Func formatter) + => Task.CompletedTask; + } + } +} diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsSummaryReporterTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsSummaryReporterTests.cs new file mode 100644 index 0000000000..1f6cdc01d1 --- /dev/null +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/GitHubActionsSummaryReporterTests.cs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.Testing.Extensions.GitHubActionsReport; + +namespace Microsoft.Testing.Extensions.UnitTests; + +[TestClass] +public sealed class GitHubActionsSummaryReporterTests +{ + [TestMethod] + public void BuildMarkdown_AllPassing_UsesSuccessIconAndTotals() + { + GitHubActionsSummaryReporter.TestRecord[] records = + [ + new("Add", "CalculatorTests.Add", GitHubActionsSummaryReporter.TerminalKind.Passed, TimeSpan.FromMilliseconds(10)), + new("Sub", "CalculatorTests.Sub", GitHubActionsSummaryReporter.TerminalKind.Passed, TimeSpan.FromMilliseconds(20)), + new("Skip", "CalculatorTests.Skip", GitHubActionsSummaryReporter.TerminalKind.Skipped, TimeSpan.Zero), + ]; + + string markdown = GitHubActionsSummaryReporter.BuildMarkdown(records, "CalculatorTests", "net9.0"); + + Assert.Contains("## ✅ Test Run Summary — CalculatorTests (net9.0)", markdown); + Assert.Contains("| 3 | 2 | 0 | 1 | 30ms |", markdown); + Assert.DoesNotContain("### ❌ Failures", markdown); + } + + [TestMethod] + public void BuildMarkdown_WithFailures_UsesFailureIconAndListsFailures() + { + GitHubActionsSummaryReporter.TestRecord[] records = + [ + new("Pass", "StringUtilsTests.Pass", GitHubActionsSummaryReporter.TerminalKind.Passed, TimeSpan.FromMilliseconds(5)), + new("Boom", "StringUtilsTests.Boom", GitHubActionsSummaryReporter.TerminalKind.Failed, TimeSpan.FromMilliseconds(7)), + ]; + + string markdown = GitHubActionsSummaryReporter.BuildMarkdown(records, "StringUtilsTests", "net9.0"); + + Assert.Contains("## ❌ Test Run Summary — StringUtilsTests (net9.0)", markdown); + Assert.Contains("### ❌ Failures (1)", markdown); + Assert.Contains("- `StringUtilsTests.Boom`", markdown); + } + + [TestMethod] + public void BuildMarkdown_EmitsSlowestTestsSortedByDuration() + { + GitHubActionsSummaryReporter.TestRecord[] records = + [ + new("Fast", "T.Fast", GitHubActionsSummaryReporter.TerminalKind.Passed, TimeSpan.FromMilliseconds(10)), + new("Slow", "T.Slow", GitHubActionsSummaryReporter.TerminalKind.Passed, TimeSpan.FromSeconds(65)), + ]; + + string markdown = GitHubActionsSummaryReporter.BuildMarkdown(records, "T", "net9.0"); + + Assert.Contains("### ⏱ Slowest tests", markdown); + int slowIndex = markdown.IndexOf("- `T.Slow` — 1m 05s", StringComparison.Ordinal); + int fastIndex = markdown.IndexOf("- `T.Fast` — 10ms", StringComparison.Ordinal); + Assert.IsGreaterThanOrEqualTo(0, slowIndex, markdown); + Assert.IsGreaterThan(slowIndex, fastIndex, markdown); + } + + [TestMethod] + public void BuildMarkdown_NoTests_StillEmitsHeaderAndZeroTotals() + { + string markdown = GitHubActionsSummaryReporter.BuildMarkdown([], "Empty", "net9.0"); + + Assert.Contains("## ✅ Test Run Summary — Empty (net9.0)", markdown); + Assert.Contains("| 0 | 0 | 0 | 0 | 0ms |", markdown); + } +} diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/Microsoft.Testing.Extensions.UnitTests.csproj b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/Microsoft.Testing.Extensions.UnitTests.csproj index 52c551cfa3..1bc17b4d2d 100644 --- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/Microsoft.Testing.Extensions.UnitTests.csproj +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/Microsoft.Testing.Extensions.UnitTests.csproj @@ -36,6 +36,9 @@ TargetFramework=netstandard2.0 + + TargetFramework=netstandard2.0 + TargetFramework=netstandard2.0