From cb1502a39d76131d16554eabefb1584710399ee4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Jare=C5=A1?= Date: Mon, 17 Mar 2025 16:20:22 +0100 Subject: [PATCH 1/6] report issues --- eng/Versions.props | 4 +- .../Terminal/TerminalTestReporter.cs | 106 ++++++++++++++++++ 2 files changed, 108 insertions(+), 2 deletions(-) diff --git a/eng/Versions.props b/eng/Versions.props index 496c0c00dc..898e64057a 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -1,9 +1,9 @@ - 3.9.0 + 3.10.0 - 1.7.0 + 1.8.0 preview diff --git a/src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/TerminalTestReporter.cs b/src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/TerminalTestReporter.cs index 56412e2dda..808f9df66d 100644 --- a/src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/TerminalTestReporter.cs +++ b/src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/TerminalTestReporter.cs @@ -1,6 +1,8 @@ // 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.ComponentModel; + using Microsoft.Testing.Platform.Helpers; using Microsoft.Testing.Platform.Resources; @@ -566,6 +568,23 @@ private static void FormatErrorMessage(ITerminal terminal, FlatException[] excep return; } + string? firstStackFrameLine = firstStackTrace?.Split(NewLineStrings, StringSplitOptions.None).FirstOrDefault(); + if (firstStackFrameLine != null) + { + (string Code, string File, int LineNumber)? location = GetStackFrameLocation(firstStackFrameLine); + if (location != null) + { + string root = RootFinder.Find(); + string file = location.Value.File; + string relativePath = file.StartsWith(root, StringComparison.CurrentCultureIgnoreCase) ? file.Substring(root.Length) : file; + + var err = AzdoEscaper.Escape(firstErrorMessage); + + terminal.AppendLine($"##vso[task.logissue type=error;sourcepath={relativePath};linenumber={location.Value.LineNumber};columnnumber=1;code=100;]{err}"); + terminal.AppendLine($"##[error]{relativePath}({location.Value.LineNumber},1): {firstErrorMessage}"); + } + } + terminal.SetColor(TerminalColor.Red); if (firstStackTrace is null) @@ -664,6 +683,30 @@ private static void AppendAssemblyLinkTargetFrameworkAndArchitecture(ITerminal t } } + internal /* for testing */ static (string Code, string File, int LineNumber)? GetStackFrameLocation(string stackTraceLine) + { + Match match = GetFrameRegex().Match(stackTraceLine); + if (!match.Success) + { + return null; + } + + bool weHaveFilePathAndCodeLine = !RoslynString.IsNullOrWhiteSpace(match.Groups["code"].Value); + if (!weHaveFilePathAndCodeLine) + { + return null; + } + + if (RoslynString.IsNullOrWhiteSpace(match.Groups["file"].Value)) + { + return null; + } + + int line = int.TryParse(match.Groups["line"].Value, out int value) ? value : 0; + + return (match.Groups["code"].Value, match.Groups["file"].Value, line); + } + internal /* for testing */ static void AppendStackFrame(ITerminal terminal, string stackTraceLine) { terminal.Append(DoubleIndentation); @@ -1038,3 +1081,66 @@ public void TestInProgress( _terminalWithProgress.UpdateWorker(asm.SlotIndex); } } + +internal static class RootFinder +{ + private static string? s_root; + + public static string Find() + { + if (s_root != null) + { + return s_root; + } + + string path = AppContext.BaseDirectory; + string dir = path; + while (Directory.GetDirectoryRoot(dir) != dir) + { + if (Directory.Exists(Path.Combine(dir, ".git"))) + { + s_root = dir + Path.DirectorySeparatorChar; + return dir + Path.DirectorySeparatorChar; + } + else + { + dir = Directory.GetParent(dir)!.ToString(); + } + } + + throw new InvalidOperationException($"Could not find solution root, .git not found in {path} or any parent directory."); + } +} + +internal static class AzdoEscaper +{ + public static string? Escape(string? value) + { + if (RoslynString.IsNullOrEmpty(value)) + { + return value; + } + + var result = new StringBuilder(value.Length); + foreach (char c in value) + { + switch (c) + { + case ';': + result.Append("%3B"); + break; + case '\r': + result.Append("%0D"); + break; + case '\n': + result.Append("%0A"); + break; + default: + result.Append(c); + break; + } + } + + return result.ToString(); + } +} From 5414878454ef49e4171c1bedac6ab9ff990f52ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Jare=C5=A1?= Date: Tue, 18 Mar 2025 11:51:08 +0100 Subject: [PATCH 2/6] Extract to extension --- TestFx.sln | 7 + .../AzDoEscaper.cs | 39 ++++ .../AzureDevOpsCommandLineOptions.cs | 10 + .../AzureDevOpsCommandLineProvider.cs | 48 +++++ .../AzureDevOpsExtensions.cs | 34 ++++ .../AzureDevOpsReporter.cs | 177 ++++++++++++++++++ .../BannedSymbols.txt | 10 + ...soft.Testing.Extensions.AzureDevOps.csproj | 52 +++++ .../PACKAGE.md | 9 + .../PublicAPI/PublicAPI.Shipped.txt | 1 + .../PublicAPI/PublicAPI.Unshipped.txt | 5 + .../Resources/AzureDevOpsResources.resx | 136 ++++++++++++++ .../Resources/xlf/AzureDevOpsResources.cs.xlf | 32 ++++ .../Resources/xlf/AzureDevOpsResources.de.xlf | 32 ++++ .../Resources/xlf/AzureDevOpsResources.es.xlf | 32 ++++ .../Resources/xlf/AzureDevOpsResources.fr.xlf | 32 ++++ .../Resources/xlf/AzureDevOpsResources.it.xlf | 32 ++++ .../Resources/xlf/AzureDevOpsResources.ja.xlf | 32 ++++ .../Resources/xlf/AzureDevOpsResources.ko.xlf | 32 ++++ .../Resources/xlf/AzureDevOpsResources.pl.xlf | 32 ++++ .../xlf/AzureDevOpsResources.pt-BR.xlf | 32 ++++ .../Resources/xlf/AzureDevOpsResources.ru.xlf | 32 ++++ .../Resources/xlf/AzureDevOpsResources.tr.xlf | 32 ++++ .../xlf/AzureDevOpsResources.zh-Hans.xlf | 32 ++++ .../xlf/AzureDevOpsResources.zh-Hant.xlf | 32 ++++ .../RootFinder.cs | 34 ++++ .../TestingPlatformBuilderHook.cs | 20 ++ ...osoft.Testing.Extensions.AzureDevOps.props | 3 + ...osoft.Testing.Extensions.AzureDevOps.props | 14 ++ ...osoft.Testing.Extensions.AzureDevOps.props | 3 + .../Helpers/StackTraceHelper.cs | 64 +++++++ .../Microsoft.Testing.Platform.csproj | 1 + .../Terminal/TerminalTestReporter.cs | 165 +--------------- 33 files changed, 1084 insertions(+), 164 deletions(-) create mode 100644 src/Platform/Microsoft.Testing.Extensions.AzureDevOps/AzDoEscaper.cs create mode 100644 src/Platform/Microsoft.Testing.Extensions.AzureDevOps/AzureDevOpsCommandLineOptions.cs create mode 100644 src/Platform/Microsoft.Testing.Extensions.AzureDevOps/AzureDevOpsCommandLineProvider.cs create mode 100644 src/Platform/Microsoft.Testing.Extensions.AzureDevOps/AzureDevOpsExtensions.cs create mode 100644 src/Platform/Microsoft.Testing.Extensions.AzureDevOps/AzureDevOpsReporter.cs create mode 100644 src/Platform/Microsoft.Testing.Extensions.AzureDevOps/BannedSymbols.txt create mode 100644 src/Platform/Microsoft.Testing.Extensions.AzureDevOps/Microsoft.Testing.Extensions.AzureDevOps.csproj create mode 100644 src/Platform/Microsoft.Testing.Extensions.AzureDevOps/PACKAGE.md create mode 100644 src/Platform/Microsoft.Testing.Extensions.AzureDevOps/PublicAPI/PublicAPI.Shipped.txt create mode 100644 src/Platform/Microsoft.Testing.Extensions.AzureDevOps/PublicAPI/PublicAPI.Unshipped.txt create mode 100644 src/Platform/Microsoft.Testing.Extensions.AzureDevOps/Resources/AzureDevOpsResources.resx create mode 100644 src/Platform/Microsoft.Testing.Extensions.AzureDevOps/Resources/xlf/AzureDevOpsResources.cs.xlf create mode 100644 src/Platform/Microsoft.Testing.Extensions.AzureDevOps/Resources/xlf/AzureDevOpsResources.de.xlf create mode 100644 src/Platform/Microsoft.Testing.Extensions.AzureDevOps/Resources/xlf/AzureDevOpsResources.es.xlf create mode 100644 src/Platform/Microsoft.Testing.Extensions.AzureDevOps/Resources/xlf/AzureDevOpsResources.fr.xlf create mode 100644 src/Platform/Microsoft.Testing.Extensions.AzureDevOps/Resources/xlf/AzureDevOpsResources.it.xlf create mode 100644 src/Platform/Microsoft.Testing.Extensions.AzureDevOps/Resources/xlf/AzureDevOpsResources.ja.xlf create mode 100644 src/Platform/Microsoft.Testing.Extensions.AzureDevOps/Resources/xlf/AzureDevOpsResources.ko.xlf create mode 100644 src/Platform/Microsoft.Testing.Extensions.AzureDevOps/Resources/xlf/AzureDevOpsResources.pl.xlf create mode 100644 src/Platform/Microsoft.Testing.Extensions.AzureDevOps/Resources/xlf/AzureDevOpsResources.pt-BR.xlf create mode 100644 src/Platform/Microsoft.Testing.Extensions.AzureDevOps/Resources/xlf/AzureDevOpsResources.ru.xlf create mode 100644 src/Platform/Microsoft.Testing.Extensions.AzureDevOps/Resources/xlf/AzureDevOpsResources.tr.xlf create mode 100644 src/Platform/Microsoft.Testing.Extensions.AzureDevOps/Resources/xlf/AzureDevOpsResources.zh-Hans.xlf create mode 100644 src/Platform/Microsoft.Testing.Extensions.AzureDevOps/Resources/xlf/AzureDevOpsResources.zh-Hant.xlf create mode 100644 src/Platform/Microsoft.Testing.Extensions.AzureDevOps/RootFinder.cs create mode 100644 src/Platform/Microsoft.Testing.Extensions.AzureDevOps/TestingPlatformBuilderHook.cs create mode 100644 src/Platform/Microsoft.Testing.Extensions.AzureDevOps/build/Microsoft.Testing.Extensions.AzureDevOps.props create mode 100644 src/Platform/Microsoft.Testing.Extensions.AzureDevOps/buildMultiTargeting/Microsoft.Testing.Extensions.AzureDevOps.props create mode 100644 src/Platform/Microsoft.Testing.Extensions.AzureDevOps/buildTransitive/Microsoft.Testing.Extensions.AzureDevOps.props create mode 100644 src/Platform/Microsoft.Testing.Platform/Helpers/StackTraceHelper.cs diff --git a/TestFx.sln b/TestFx.sln index d8d48028b3..53b4a4b320 100644 --- a/TestFx.sln +++ b/TestFx.sln @@ -222,6 +222,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MSTest.Engine.UnitTests", " EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MSTest.SourceGeneration.UnitTests", "test\UnitTests\MSTest.SourceGeneration.UnitTests\MSTest.SourceGeneration.UnitTests.csproj", "{E6C0466E-BE8D-C04F-149A-FD98438F1413}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.Testing.Extensions.AzureDevOps", "src\Platform\Microsoft.Testing.Extensions.AzureDevOps\Microsoft.Testing.Extensions.AzureDevOps.csproj", "{F608D3A3-125B-CD88-1D51-8714ED142029}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -524,6 +526,10 @@ Global {E6C0466E-BE8D-C04F-149A-FD98438F1413}.Debug|Any CPU.Build.0 = Debug|Any CPU {E6C0466E-BE8D-C04F-149A-FD98438F1413}.Release|Any CPU.ActiveCfg = Release|Any CPU {E6C0466E-BE8D-C04F-149A-FD98438F1413}.Release|Any CPU.Build.0 = Release|Any CPU + {F608D3A3-125B-CD88-1D51-8714ED142029}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F608D3A3-125B-CD88-1D51-8714ED142029}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F608D3A3-125B-CD88-1D51-8714ED142029}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F608D3A3-125B-CD88-1D51-8714ED142029}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -615,6 +621,7 @@ Global {7BA0E74E-798E-4399-2EDE-A23BD5DA78CA} = {E7F15C9C-3928-47AD-8462-64FD29FFCA54} {2C0DFAC0-5D58-D172-ECE4-CBB78AD03435} = {BB874DF1-44FE-415A-B634-A6B829107890} {E6C0466E-BE8D-C04F-149A-FD98438F1413} = {BB874DF1-44FE-415A-B634-A6B829107890} + {F608D3A3-125B-CD88-1D51-8714ED142029} = {6AEE1440-FDF0-4729-8196-B24D0E333550} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {31E0F4D5-975A-41CC-933E-545B2201FAF9} diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/AzDoEscaper.cs b/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/AzDoEscaper.cs new file mode 100644 index 0000000000..be84d82dda --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/AzDoEscaper.cs @@ -0,0 +1,39 @@ +// 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.TrxReport.Abstractions; + +internal static class AzDoEscaper +{ + public static string Escape(string value) + { + if (RoslynString.IsNullOrEmpty(value)) + { + return value; + } + + var result = new StringBuilder(value.Length); + foreach (char c in value) + { + switch (c) + { + case ';': + result.Append("%3B"); + 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.AzureDevOps/AzureDevOpsCommandLineOptions.cs b/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/AzureDevOpsCommandLineOptions.cs new file mode 100644 index 0000000000..bfe0b50025 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/AzureDevOpsCommandLineOptions.cs @@ -0,0 +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. + +namespace Microsoft.Testing.Extensions.Reporting; + +internal static class AzureDevOpsCommandLineOptions +{ + public const string AzureDevOpsOptionName = "report-azdo"; + public const string AzureDevOpsReportSeverity = "report-azdo-severity"; +} diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/AzureDevOpsCommandLineProvider.cs b/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/AzureDevOpsCommandLineProvider.cs new file mode 100644 index 0000000000..c178db8c74 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/AzureDevOpsCommandLineProvider.cs @@ -0,0 +1,48 @@ +// 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.Reporting.Resources; +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.Reporting; + +internal sealed class AzureDevOpsCommandLineProvider : ICommandLineOptionsProvider +{ + private static readonly string[] SeverityOptions = ["error", "warning"]; + + public string Uid => nameof(AzureDevOpsCommandLineProvider); + + public string Version => AppVersion.DefaultSemVer; + + public string DisplayName => AzureDevOpsResources.DisplayName; + + public string Description => AzureDevOpsResources.Description; + + public Task IsEnabledAsync() => Task.FromResult(true); + + public IReadOnlyCollection GetCommandLineOptions() + => + [ + new CommandLineOption(AzureDevOpsCommandLineOptions.AzureDevOpsOptionName, AzureDevOpsResources.OptionDescription, ArgumentArity.Zero, false), + new CommandLineOption(AzureDevOpsCommandLineOptions.AzureDevOpsReportSeverity, AzureDevOpsResources.SeverityOptionDescription, ArgumentArity.ExactlyOne, false), + ]; + + public Task ValidateOptionArgumentsAsync(CommandLineOption commandOption, string[] arguments) + { + if (commandOption.Name == AzureDevOpsCommandLineOptions.AzureDevOpsReportSeverity) + { + if (!SeverityOptions.Contains(arguments[0], StringComparer.OrdinalIgnoreCase)) + { + return ValidationResult.InvalidTask(string.Format(CultureInfo.InvariantCulture, AzureDevOpsResources.InvalidSeverity, arguments[0])); + } + } + + return ValidationResult.ValidTask; + } + + public Task ValidateCommandLineOptionsAsync(ICommandLineOptions commandLineOptions) + => ValidationResult.ValidTask; +} diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/AzureDevOpsExtensions.cs b/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/AzureDevOpsExtensions.cs new file mode 100644 index 0000000000..81bc0470b4 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/AzureDevOpsExtensions.cs @@ -0,0 +1,34 @@ +// 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.Reporting; +using Microsoft.Testing.Extensions.TrxReport.Abstractions; +using Microsoft.Testing.Platform.Builder; +using Microsoft.Testing.Platform.Extensions; +using Microsoft.Testing.Platform.Services; + +namespace Microsoft.Testing.Extensions; + +/// +/// Provides extension methods for adding Azure DevOps reporting support to the test application builder. +/// +public static class AzureDevOpsExtensions +{ + /// + /// Adds support to the test application builder. + /// + /// The test application builder. + public static void AddAzureDevOpsProvider(this ITestApplicationBuilder builder) + { + var compositeTestSessionAzDoService = + new CompositeExtensionFactory(serviceProvider => + new AzureDevOpsReporter( + serviceProvider.GetCommandLineOptions(), + serviceProvider.GetEnvironment(), + serviceProvider.GetOutputDevice())); + + builder.TestHost.AddDataConsumer(compositeTestSessionAzDoService); + + builder.CommandLine.AddProvider(() => new AzureDevOpsCommandLineProvider()); + } +} diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/AzureDevOpsReporter.cs b/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/AzureDevOpsReporter.cs new file mode 100644 index 0000000000..6f42dc4466 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/AzureDevOpsReporter.cs @@ -0,0 +1,177 @@ +// 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.Reporting; +using Microsoft.Testing.Extensions.Reporting.Resources; +using Microsoft.Testing.Platform; +using Microsoft.Testing.Platform.CommandLine; +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.OutputDevice; + +namespace Microsoft.Testing.Extensions.TrxReport.Abstractions; + +internal sealed class AzureDevOpsReporter : + IDataConsumer, + IDataProducer, + IOutputDeviceDataProducer +{ + private readonly IOutputDevice _outputDisplay; + + private static readonly char[] NewlineCharacters = new char[] { '\r', '\n' }; + private readonly ICommandLineOptions _commandLine; + private readonly IEnvironment _environment; + private string _severity = "error"; + + public AzureDevOpsReporter( + ICommandLineOptions commandLine, + IEnvironment environment, + IOutputDevice outputDisplay) + { + _commandLine = commandLine; + _environment = environment; + _outputDisplay = outputDisplay; + } + + public Type[] DataTypesConsumed { get; } = + [ + typeof(TestNodeUpdateMessage) + ]; + + public Type[] DataTypesProduced { get; } = [typeof(SessionFileArtifact)]; + + /// + public string Uid { get; } = nameof(AzureDevOpsReporter); + + /// + public string Version { get; } = AppVersion.DefaultSemVer; + + /// + public string DisplayName { get; } = AzureDevOpsResources.DisplayName; + + /// + public string Description { get; } = AzureDevOpsResources.Description; + + /// + public Task IsEnabledAsync() + { + bool isEnabled = _commandLine.IsOptionSet(AzureDevOpsCommandLineOptions.AzureDevOpsOptionName) + && string.Equals(_environment.GetEnvironmentVariable("TF_BUILD"), "true", StringComparison.OrdinalIgnoreCase); + + if (isEnabled) + { + bool found = _commandLine.TryGetOptionArgumentList(AzureDevOpsCommandLineOptions.AzureDevOpsReportSeverity, out string[]? arguments); + if (found && arguments?.Length > 0) + { + if (string.Equals(arguments[0], "warning", StringComparison.OrdinalIgnoreCase)) + { + _severity = "warning"; + } + } + } + + return Task.FromResult(isEnabled); + } + + public async Task ConsumeAsync(IDataProducer dataProducer, IData value, CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) + { + return; + } + + try + { + if (value is not TestNodeUpdateMessage nodeUpdateMessage) + { + return; + } + + TestNodeStateProperty nodeState = nodeUpdateMessage.TestNode.Properties.Single(); + + switch (nodeState) + { + case FailedTestNodeStateProperty failed: + await WriteExceptionAsync(failed.Explanation, failed.Exception); + break; + case ErrorTestNodeStateProperty error: + await WriteExceptionAsync(error.Explanation, error.Exception); + break; + case CancelledTestNodeStateProperty cancelled: + await WriteExceptionAsync(cancelled.Explanation, cancelled.Exception); + break; + case TimeoutTestNodeStateProperty timeout: + await WriteExceptionAsync(timeout.Explanation, timeout.Exception); + break; + } + + return; + } + catch (OperationCanceledException ex) when (ex.CancellationToken == cancellationToken) + { + // Do nothing, we're stopping + } + + return; + } + + private async Task WriteExceptionAsync(string? explanation, Exception? exception) + { + if (exception == null || exception.StackTrace == null) + { + return; + } + + string message = explanation ?? exception.Message; + + if (message == null) + { + return; + } + + string stackTrace = exception.StackTrace; + int index = stackTrace.IndexOfAny(NewlineCharacters); + string firstLine = index == -1 ? stackTrace : stackTrace.Substring(0, index); + if (firstLine != null) + { + (string Code, string File, int LineNumber)? location = GetStackFrameLocation(firstLine); + if (location != null) + { + string root = RootFinder.Find(); + string file = location.Value.File; + string relativePath = file.StartsWith(root, StringComparison.CurrentCultureIgnoreCase) ? file.Substring(root.Length) : file; + + string err = AzDoEscaper.Escape(message); + + string line = $"##vso[task.logissue type={_severity};sourcepath={relativePath};linenumber={location.Value.LineNumber};columnnumber=1]{err}"; + await _outputDisplay.DisplayAsync(this, new FormattedTextOutputDeviceData(line)); + } + } + } + + internal /* for testing */ static (string Code, string File, int LineNumber)? GetStackFrameLocation(string stackTraceLine) + { + Match match = StackTraceHelper.GetFrameRegex().Match(stackTraceLine); + if (!match.Success) + { + return null; + } + + bool weHaveFilePathAndCodeLine = !RoslynString.IsNullOrWhiteSpace(match.Groups["code"].Value); + if (!weHaveFilePathAndCodeLine) + { + return null; + } + + if (RoslynString.IsNullOrWhiteSpace(match.Groups["file"].Value)) + { + return null; + } + + int line = int.TryParse(match.Groups["line"].Value, out int value) ? value : 0; + + return (match.Groups["code"].Value, match.Groups["file"].Value, line); + } +} diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/BannedSymbols.txt b/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/BannedSymbols.txt new file mode 100644 index 0000000000..ea8617fcb0 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/BannedSymbols.txt @@ -0,0 +1,10 @@ +T:System.ArgumentNullException; Use 'ArgumentGuard' instead +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.AzureDevOps/Microsoft.Testing.Extensions.AzureDevOps.csproj b/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/Microsoft.Testing.Extensions.AzureDevOps.csproj new file mode 100644 index 0000000000..a3ebb0c6bd --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/Microsoft.Testing.Extensions.AzureDevOps.csproj @@ -0,0 +1,52 @@ + + + + netstandard2.0;$(MicrosoftTestingTargetFrameworks) + Microsoft.Testing.Extensions.Reporting + + + + + + + + + + + + + + true + buildMultiTargeting + + + buildTransitive/$(TargetFramework) + + + build/$(TargetFramework) + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/PACKAGE.md b/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/PACKAGE.md new file mode 100644 index 0000000000..214ac847f6 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/PACKAGE.md @@ -0,0 +1,9 @@ +# Microsoft.Testing + +Microsoft Testing is a set of platform, framework and protocol intended to make it possible to run any test on any target or device. + +Documentation can be found at . + +## About + +This package extends Microsoft Testing Platform to provide Azure DevOps reporting. diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/PublicAPI/PublicAPI.Shipped.txt b/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/PublicAPI/PublicAPI.Shipped.txt new file mode 100644 index 0000000000..7dc5c58110 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/PublicAPI/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/PublicAPI/PublicAPI.Unshipped.txt b/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/PublicAPI/PublicAPI.Unshipped.txt new file mode 100644 index 0000000000..eabd9f205a --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/PublicAPI/PublicAPI.Unshipped.txt @@ -0,0 +1,5 @@ +#nullable enable +Microsoft.Testing.Extensions.AzureDevOps.TestingPlatformBuilderHook +Microsoft.Testing.Extensions.AzureDevOpsExtensions +static Microsoft.Testing.Extensions.AzureDevOps.TestingPlatformBuilderHook.AddExtensions(Microsoft.Testing.Platform.Builder.ITestApplicationBuilder! testApplicationBuilder, string![]! _) -> void +static Microsoft.Testing.Extensions.AzureDevOpsExtensions.AddAzureDevOpsProvider(this Microsoft.Testing.Platform.Builder.ITestApplicationBuilder! builder) -> void diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/Resources/AzureDevOpsResources.resx b/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/Resources/AzureDevOpsResources.resx new file mode 100644 index 0000000000..8114f1a73b --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/Resources/AzureDevOpsResources.resx @@ -0,0 +1,136 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 + + + Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + + + Azure DevOps report generator + + + Invalid option {0}. + + + Eanble Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + + + Severity to use for the reported event. Options are: error (default) and warning. + Do not translated 'error' or 'warning' those are literal values. + + \ No newline at end of file diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/Resources/xlf/AzureDevOpsResources.cs.xlf b/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/Resources/xlf/AzureDevOpsResources.cs.xlf new file mode 100644 index 0000000000..fc0b855a4b --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/Resources/xlf/AzureDevOpsResources.cs.xlf @@ -0,0 +1,32 @@ + + + + + + Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + + + + Azure DevOps report generator + Azure DevOps report generator + + + + Invalid option {0}. + Invalid option {0}. + + + + Eanble Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + Eanble Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + + + + Severity to use for the reported event. Options are: error (default) and warning. + Severity to use for the reported event. Options are: error (default) and warning. + Do not translated 'error' or 'warning' those are literal values. + + + + \ No newline at end of file diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/Resources/xlf/AzureDevOpsResources.de.xlf b/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/Resources/xlf/AzureDevOpsResources.de.xlf new file mode 100644 index 0000000000..9a6ee72bc6 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/Resources/xlf/AzureDevOpsResources.de.xlf @@ -0,0 +1,32 @@ + + + + + + Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + + + + Azure DevOps report generator + Azure DevOps report generator + + + + Invalid option {0}. + Invalid option {0}. + + + + Eanble Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + Eanble Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + + + + Severity to use for the reported event. Options are: error (default) and warning. + Severity to use for the reported event. Options are: error (default) and warning. + Do not translated 'error' or 'warning' those are literal values. + + + + \ No newline at end of file diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/Resources/xlf/AzureDevOpsResources.es.xlf b/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/Resources/xlf/AzureDevOpsResources.es.xlf new file mode 100644 index 0000000000..c3877b772e --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/Resources/xlf/AzureDevOpsResources.es.xlf @@ -0,0 +1,32 @@ + + + + + + Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + + + + Azure DevOps report generator + Azure DevOps report generator + + + + Invalid option {0}. + Invalid option {0}. + + + + Eanble Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + Eanble Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + + + + Severity to use for the reported event. Options are: error (default) and warning. + Severity to use for the reported event. Options are: error (default) and warning. + Do not translated 'error' or 'warning' those are literal values. + + + + \ No newline at end of file diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/Resources/xlf/AzureDevOpsResources.fr.xlf b/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/Resources/xlf/AzureDevOpsResources.fr.xlf new file mode 100644 index 0000000000..2fb2366fd4 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/Resources/xlf/AzureDevOpsResources.fr.xlf @@ -0,0 +1,32 @@ + + + + + + Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + + + + Azure DevOps report generator + Azure DevOps report generator + + + + Invalid option {0}. + Invalid option {0}. + + + + Eanble Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + Eanble Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + + + + Severity to use for the reported event. Options are: error (default) and warning. + Severity to use for the reported event. Options are: error (default) and warning. + Do not translated 'error' or 'warning' those are literal values. + + + + \ No newline at end of file diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/Resources/xlf/AzureDevOpsResources.it.xlf b/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/Resources/xlf/AzureDevOpsResources.it.xlf new file mode 100644 index 0000000000..ca1921e41b --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/Resources/xlf/AzureDevOpsResources.it.xlf @@ -0,0 +1,32 @@ + + + + + + Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + + + + Azure DevOps report generator + Azure DevOps report generator + + + + Invalid option {0}. + Invalid option {0}. + + + + Eanble Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + Eanble Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + + + + Severity to use for the reported event. Options are: error (default) and warning. + Severity to use for the reported event. Options are: error (default) and warning. + Do not translated 'error' or 'warning' those are literal values. + + + + \ No newline at end of file diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/Resources/xlf/AzureDevOpsResources.ja.xlf b/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/Resources/xlf/AzureDevOpsResources.ja.xlf new file mode 100644 index 0000000000..1ebedda752 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/Resources/xlf/AzureDevOpsResources.ja.xlf @@ -0,0 +1,32 @@ + + + + + + Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + + + + Azure DevOps report generator + Azure DevOps report generator + + + + Invalid option {0}. + Invalid option {0}. + + + + Eanble Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + Eanble Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + + + + Severity to use for the reported event. Options are: error (default) and warning. + Severity to use for the reported event. Options are: error (default) and warning. + Do not translated 'error' or 'warning' those are literal values. + + + + \ No newline at end of file diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/Resources/xlf/AzureDevOpsResources.ko.xlf b/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/Resources/xlf/AzureDevOpsResources.ko.xlf new file mode 100644 index 0000000000..72115a171c --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/Resources/xlf/AzureDevOpsResources.ko.xlf @@ -0,0 +1,32 @@ + + + + + + Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + + + + Azure DevOps report generator + Azure DevOps report generator + + + + Invalid option {0}. + Invalid option {0}. + + + + Eanble Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + Eanble Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + + + + Severity to use for the reported event. Options are: error (default) and warning. + Severity to use for the reported event. Options are: error (default) and warning. + Do not translated 'error' or 'warning' those are literal values. + + + + \ No newline at end of file diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/Resources/xlf/AzureDevOpsResources.pl.xlf b/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/Resources/xlf/AzureDevOpsResources.pl.xlf new file mode 100644 index 0000000000..6bf78a2580 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/Resources/xlf/AzureDevOpsResources.pl.xlf @@ -0,0 +1,32 @@ + + + + + + Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + + + + Azure DevOps report generator + Azure DevOps report generator + + + + Invalid option {0}. + Invalid option {0}. + + + + Eanble Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + Eanble Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + + + + Severity to use for the reported event. Options are: error (default) and warning. + Severity to use for the reported event. Options are: error (default) and warning. + Do not translated 'error' or 'warning' those are literal values. + + + + \ No newline at end of file diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/Resources/xlf/AzureDevOpsResources.pt-BR.xlf b/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/Resources/xlf/AzureDevOpsResources.pt-BR.xlf new file mode 100644 index 0000000000..29986e2a74 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/Resources/xlf/AzureDevOpsResources.pt-BR.xlf @@ -0,0 +1,32 @@ + + + + + + Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + + + + Azure DevOps report generator + Azure DevOps report generator + + + + Invalid option {0}. + Invalid option {0}. + + + + Eanble Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + Eanble Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + + + + Severity to use for the reported event. Options are: error (default) and warning. + Severity to use for the reported event. Options are: error (default) and warning. + Do not translated 'error' or 'warning' those are literal values. + + + + \ No newline at end of file diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/Resources/xlf/AzureDevOpsResources.ru.xlf b/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/Resources/xlf/AzureDevOpsResources.ru.xlf new file mode 100644 index 0000000000..1495ec1d00 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/Resources/xlf/AzureDevOpsResources.ru.xlf @@ -0,0 +1,32 @@ + + + + + + Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + + + + Azure DevOps report generator + Azure DevOps report generator + + + + Invalid option {0}. + Invalid option {0}. + + + + Eanble Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + Eanble Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + + + + Severity to use for the reported event. Options are: error (default) and warning. + Severity to use for the reported event. Options are: error (default) and warning. + Do not translated 'error' or 'warning' those are literal values. + + + + \ No newline at end of file diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/Resources/xlf/AzureDevOpsResources.tr.xlf b/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/Resources/xlf/AzureDevOpsResources.tr.xlf new file mode 100644 index 0000000000..f9aab364ab --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/Resources/xlf/AzureDevOpsResources.tr.xlf @@ -0,0 +1,32 @@ + + + + + + Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + + + + Azure DevOps report generator + Azure DevOps report generator + + + + Invalid option {0}. + Invalid option {0}. + + + + Eanble Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + Eanble Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + + + + Severity to use for the reported event. Options are: error (default) and warning. + Severity to use for the reported event. Options are: error (default) and warning. + Do not translated 'error' or 'warning' those are literal values. + + + + \ No newline at end of file diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/Resources/xlf/AzureDevOpsResources.zh-Hans.xlf b/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/Resources/xlf/AzureDevOpsResources.zh-Hans.xlf new file mode 100644 index 0000000000..460f2cd2df --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/Resources/xlf/AzureDevOpsResources.zh-Hans.xlf @@ -0,0 +1,32 @@ + + + + + + Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + + + + Azure DevOps report generator + Azure DevOps report generator + + + + Invalid option {0}. + Invalid option {0}. + + + + Eanble Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + Eanble Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + + + + Severity to use for the reported event. Options are: error (default) and warning. + Severity to use for the reported event. Options are: error (default) and warning. + Do not translated 'error' or 'warning' those are literal values. + + + + \ No newline at end of file diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/Resources/xlf/AzureDevOpsResources.zh-Hant.xlf b/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/Resources/xlf/AzureDevOpsResources.zh-Hant.xlf new file mode 100644 index 0000000000..62c67b8235 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/Resources/xlf/AzureDevOpsResources.zh-Hant.xlf @@ -0,0 +1,32 @@ + + + + + + Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + + + + Azure DevOps report generator + Azure DevOps report generator + + + + Invalid option {0}. + Invalid option {0}. + + + + Eanble Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + Eanble Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + + + + Severity to use for the reported event. Options are: error (default) and warning. + Severity to use for the reported event. Options are: error (default) and warning. + Do not translated 'error' or 'warning' those are literal values. + + + + \ No newline at end of file diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/RootFinder.cs b/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/RootFinder.cs new file mode 100644 index 0000000000..b7989fe210 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/RootFinder.cs @@ -0,0 +1,34 @@ +// 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.TrxReport.Abstractions; + +internal static class RootFinder +{ + private static string? s_root; + + public static string Find() + { + if (s_root != null) + { + return s_root; + } + + string path = AppContext.BaseDirectory; + string dir = path; + while (Directory.GetDirectoryRoot(dir) != dir) + { + if (Directory.Exists(Path.Combine(dir, ".git"))) + { + s_root = dir + Path.DirectorySeparatorChar; + return dir + Path.DirectorySeparatorChar; + } + else + { + dir = Directory.GetParent(dir)!.ToString(); + } + } + + throw new InvalidOperationException($"Could not find solution root, .git not found in {path} or any parent directory."); + } +} diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/TestingPlatformBuilderHook.cs b/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/TestingPlatformBuilderHook.cs new file mode 100644 index 0000000000..930066693a --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/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.AzureDevOps; + +/// +/// This class is used by Microsoft.Testing.Platform.MSBuild to hook into the Testing Platform Builder to add Azure DevOps reporting support. +/// +public static class TestingPlatformBuilderHook +{ + /// + /// Adds Azure DevOps reporting support to the Testing Platform Builder. + /// + /// The test application builder. + /// The command line arguments. + public static void AddExtensions(ITestApplicationBuilder testApplicationBuilder, string[] _) + => testApplicationBuilder.AddAzureDevOpsProvider(); +} diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/build/Microsoft.Testing.Extensions.AzureDevOps.props b/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/build/Microsoft.Testing.Extensions.AzureDevOps.props new file mode 100644 index 0000000000..57f9f713e2 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/build/Microsoft.Testing.Extensions.AzureDevOps.props @@ -0,0 +1,3 @@ + + + diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/buildMultiTargeting/Microsoft.Testing.Extensions.AzureDevOps.props b/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/buildMultiTargeting/Microsoft.Testing.Extensions.AzureDevOps.props new file mode 100644 index 0000000000..9cd86af126 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/buildMultiTargeting/Microsoft.Testing.Extensions.AzureDevOps.props @@ -0,0 +1,14 @@ + + + + + + Microsoft.Testing.Extensions.AzureDevOps + Microsoft.Testing.Extensions.AzureDevOps.TestingPlatformBuilderHook + + + diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/buildTransitive/Microsoft.Testing.Extensions.AzureDevOps.props b/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/buildTransitive/Microsoft.Testing.Extensions.AzureDevOps.props new file mode 100644 index 0000000000..57f9f713e2 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/buildTransitive/Microsoft.Testing.Extensions.AzureDevOps.props @@ -0,0 +1,3 @@ + + + diff --git a/src/Platform/Microsoft.Testing.Platform/Helpers/StackTraceHelper.cs b/src/Platform/Microsoft.Testing.Platform/Helpers/StackTraceHelper.cs new file mode 100644 index 0000000000..c36c6a0af7 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Platform/Helpers/StackTraceHelper.cs @@ -0,0 +1,64 @@ +// 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.Platform.Helpers; + +internal static partial class StackTraceHelper +{ +#if NET7_0_OR_GREATER + // Specifying no timeout, the regex is linear. And the timeout does not measure the regex only, but measures also any + // thread suspends, so the regex gets blamed incorrectly. + [GeneratedRegex(@"^ at ((?.+) in (?.+):line (?\d+)|(?.+))$", RegexOptions.ExplicitCapture)] + public static partial Regex GetFrameRegex(); +#else + private static Regex? s_regex; + + [MemberNotNull(nameof(s_regex))] + public static Regex GetFrameRegex() + { + if (s_regex != null) + { + return s_regex; + } + + string atResourceName = "Word_At"; + string inResourceName = "StackTrace_InFileLineNumber"; + + string? atString = null; + string? inString = null; + + // Grab words from localized resource, in case the stack trace is localized. + try + { + // Get these resources: https://github.com/dotnet/runtime/blob/main/src/libraries/System.Private.CoreLib/src/Resources/Strings.resx +#pragma warning disable RS0030 // Do not use banned APIs + MethodInfo? getResourceStringMethod = typeof(Environment).GetMethod( + "GetResourceString", + BindingFlags.Static | BindingFlags.NonPublic, null, [typeof(string)], null); +#pragma warning restore RS0030 // Do not use banned APIs + if (getResourceStringMethod is not null) + { + // at + atString = (string?)getResourceStringMethod.Invoke(null, [atResourceName]); + + // in {0}:line {1} + inString = (string?)getResourceStringMethod.Invoke(null, [inResourceName]); + } + } + catch + { + // If we fail, populate the defaults below. + } + + atString = atString == null || atString == atResourceName ? "at" : atString; + inString = inString == null || inString == inResourceName ? "in {0}:line {1}" : inString; + + string inPattern = string.Format(CultureInfo.InvariantCulture, inString, "(?.+)", @"(?\d+)"); + + // Specifying no timeout, the regex is linear. And the timeout does not measure the regex only, but measures also any + // thread suspends, so the regex gets blamed incorrectly. + s_regex = new Regex(@$"^ {atString} ((?.+) {inPattern}|(?.+))$", RegexOptions.Compiled | RegexOptions.ExplicitCapture); + return s_regex; + } +#endif +} diff --git a/src/Platform/Microsoft.Testing.Platform/Microsoft.Testing.Platform.csproj b/src/Platform/Microsoft.Testing.Platform/Microsoft.Testing.Platform.csproj index f9a9f82f32..13ce520dbb 100644 --- a/src/Platform/Microsoft.Testing.Platform/Microsoft.Testing.Platform.csproj +++ b/src/Platform/Microsoft.Testing.Platform/Microsoft.Testing.Platform.csproj @@ -37,6 +37,7 @@ This package provides the core platform and the .NET implementation of the proto + diff --git a/src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/TerminalTestReporter.cs b/src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/TerminalTestReporter.cs index 808f9df66d..273aa97448 100644 --- a/src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/TerminalTestReporter.cs +++ b/src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/TerminalTestReporter.cs @@ -1,8 +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 System.ComponentModel; - using Microsoft.Testing.Platform.Helpers; using Microsoft.Testing.Platform.Resources; @@ -57,63 +55,6 @@ internal event EventHandler OnProgressStopUpdate private bool? _shouldShowPassedTests; -#if NET7_0_OR_GREATER - // Specifying no timeout, the regex is linear. And the timeout does not measure the regex only, but measures also any - // thread suspends, so the regex gets blamed incorrectly. - [GeneratedRegex(@"^ at ((?.+) in (?.+):line (?\d+)|(?.+))$", RegexOptions.ExplicitCapture)] - private static partial Regex GetFrameRegex(); -#else - private static Regex? s_regex; - - [MemberNotNull(nameof(s_regex))] - private static Regex GetFrameRegex() - { - if (s_regex != null) - { - return s_regex; - } - - string atResourceName = "Word_At"; - string inResourceName = "StackTrace_InFileLineNumber"; - - string? atString = null; - string? inString = null; - - // Grab words from localized resource, in case the stack trace is localized. - try - { - // Get these resources: https://github.com/dotnet/runtime/blob/main/src/libraries/System.Private.CoreLib/src/Resources/Strings.resx -#pragma warning disable RS0030 // Do not use banned APIs - MethodInfo? getResourceStringMethod = typeof(Environment).GetMethod( - "GetResourceString", - BindingFlags.Static | BindingFlags.NonPublic, null, [typeof(string)], null); -#pragma warning restore RS0030 // Do not use banned APIs - if (getResourceStringMethod is not null) - { - // at - atString = (string?)getResourceStringMethod.Invoke(null, [atResourceName]); - - // in {0}:line {1} - inString = (string?)getResourceStringMethod.Invoke(null, [inResourceName]); - } - } - catch - { - // If we fail, populate the defaults below. - } - - atString = atString == null || atString == atResourceName ? "at" : atString; - inString = inString == null || inString == inResourceName ? "in {0}:line {1}" : inString; - - string inPattern = string.Format(CultureInfo.InvariantCulture, inString, "(?.+)", @"(?\d+)"); - - // Specifying no timeout, the regex is linear. And the timeout does not measure the regex only, but measures also any - // thread suspends, so the regex gets blamed incorrectly. - s_regex = new Regex(@$"^ {atString} ((?.+) {inPattern}|(?.+))$", RegexOptions.Compiled | RegexOptions.ExplicitCapture); - return s_regex; - } -#endif - private int _counter; /// @@ -568,23 +509,6 @@ private static void FormatErrorMessage(ITerminal terminal, FlatException[] excep return; } - string? firstStackFrameLine = firstStackTrace?.Split(NewLineStrings, StringSplitOptions.None).FirstOrDefault(); - if (firstStackFrameLine != null) - { - (string Code, string File, int LineNumber)? location = GetStackFrameLocation(firstStackFrameLine); - if (location != null) - { - string root = RootFinder.Find(); - string file = location.Value.File; - string relativePath = file.StartsWith(root, StringComparison.CurrentCultureIgnoreCase) ? file.Substring(root.Length) : file; - - var err = AzdoEscaper.Escape(firstErrorMessage); - - terminal.AppendLine($"##vso[task.logissue type=error;sourcepath={relativePath};linenumber={location.Value.LineNumber};columnnumber=1;code=100;]{err}"); - terminal.AppendLine($"##[error]{relativePath}({location.Value.LineNumber},1): {firstErrorMessage}"); - } - } - terminal.SetColor(TerminalColor.Red); if (firstStackTrace is null) @@ -683,34 +607,10 @@ private static void AppendAssemblyLinkTargetFrameworkAndArchitecture(ITerminal t } } - internal /* for testing */ static (string Code, string File, int LineNumber)? GetStackFrameLocation(string stackTraceLine) - { - Match match = GetFrameRegex().Match(stackTraceLine); - if (!match.Success) - { - return null; - } - - bool weHaveFilePathAndCodeLine = !RoslynString.IsNullOrWhiteSpace(match.Groups["code"].Value); - if (!weHaveFilePathAndCodeLine) - { - return null; - } - - if (RoslynString.IsNullOrWhiteSpace(match.Groups["file"].Value)) - { - return null; - } - - int line = int.TryParse(match.Groups["line"].Value, out int value) ? value : 0; - - return (match.Groups["code"].Value, match.Groups["file"].Value, line); - } - internal /* for testing */ static void AppendStackFrame(ITerminal terminal, string stackTraceLine) { terminal.Append(DoubleIndentation); - Match match = GetFrameRegex().Match(stackTraceLine); + Match match = StackTraceHelper.GetFrameRegex().Match(stackTraceLine); if (match.Success) { bool weHaveFilePathAndCodeLine = !RoslynString.IsNullOrWhiteSpace(match.Groups["code"].Value); @@ -1081,66 +981,3 @@ public void TestInProgress( _terminalWithProgress.UpdateWorker(asm.SlotIndex); } } - -internal static class RootFinder -{ - private static string? s_root; - - public static string Find() - { - if (s_root != null) - { - return s_root; - } - - string path = AppContext.BaseDirectory; - string dir = path; - while (Directory.GetDirectoryRoot(dir) != dir) - { - if (Directory.Exists(Path.Combine(dir, ".git"))) - { - s_root = dir + Path.DirectorySeparatorChar; - return dir + Path.DirectorySeparatorChar; - } - else - { - dir = Directory.GetParent(dir)!.ToString(); - } - } - - throw new InvalidOperationException($"Could not find solution root, .git not found in {path} or any parent directory."); - } -} - -internal static class AzdoEscaper -{ - public static string? Escape(string? value) - { - if (RoslynString.IsNullOrEmpty(value)) - { - return value; - } - - var result = new StringBuilder(value.Length); - foreach (char c in value) - { - switch (c) - { - case ';': - result.Append("%3B"); - break; - case '\r': - result.Append("%0D"); - break; - case '\n': - result.Append("%0A"); - break; - default: - result.Append(c); - break; - } - } - - return result.ToString(); - } -} From df6ee2d58b31a8a39b40c4b8bb041544ae21d258 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Jare=C5=A1?= Date: Tue, 18 Mar 2025 12:00:34 +0100 Subject: [PATCH 3/6] revert versioning --- eng/Versions.props | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/eng/Versions.props b/eng/Versions.props index 898e64057a..496c0c00dc 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -1,9 +1,9 @@ - 3.10.0 + 3.9.0 - 1.8.0 + 1.7.0 preview From 4435ad1ff7a9afd39b0f213dd47f6740667882a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Jare=C5=A1?= Date: Tue, 18 Mar 2025 12:14:18 +0100 Subject: [PATCH 4/6] namespace, and indentation --- .../Microsoft.Testing.Extensions.AzureDevOps/AzDoEscaper.cs | 2 +- .../AzureDevOpsExtensions.cs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/AzDoEscaper.cs b/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/AzDoEscaper.cs index be84d82dda..c64862bcc9 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/AzDoEscaper.cs +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/AzDoEscaper.cs @@ -3,7 +3,7 @@ using Microsoft.Testing.Platform; -namespace Microsoft.Testing.Extensions.TrxReport.Abstractions; +namespace Microsoft.Testing.Extensions.Reporting; internal static class AzDoEscaper { diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/AzureDevOpsExtensions.cs b/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/AzureDevOpsExtensions.cs index 81bc0470b4..696ee37572 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/AzureDevOpsExtensions.cs +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/AzureDevOpsExtensions.cs @@ -23,9 +23,9 @@ public static void AddAzureDevOpsProvider(this ITestApplicationBuilder builder) var compositeTestSessionAzDoService = new CompositeExtensionFactory(serviceProvider => new AzureDevOpsReporter( - serviceProvider.GetCommandLineOptions(), - serviceProvider.GetEnvironment(), - serviceProvider.GetOutputDevice())); + serviceProvider.GetCommandLineOptions(), + serviceProvider.GetEnvironment(), + serviceProvider.GetOutputDevice())); builder.TestHost.AddDataConsumer(compositeTestSessionAzDoService); From fe56275415d8b219b1652d42e57c22bfa6061100 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Jare=C5=A1?= Date: Wed, 19 Mar 2025 09:40:27 +0100 Subject: [PATCH 5/6] Pr review feedbacks --- .../AzureDevOpsCommandLineProvider.cs | 2 +- .../AzureDevOpsReporter.cs | 51 ++++++++----------- ...soft.Testing.Extensions.AzureDevOps.csproj | 11 ++-- .../RootFinder.cs | 2 +- 4 files changed, 26 insertions(+), 40 deletions(-) diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/AzureDevOpsCommandLineProvider.cs b/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/AzureDevOpsCommandLineProvider.cs index c178db8c74..3727cd22fd 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/AzureDevOpsCommandLineProvider.cs +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/AzureDevOpsCommandLineProvider.cs @@ -1,7 +1,7 @@ // 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.Reporting.Resources; +using Microsoft.Testing.Extensions.AzureDevOps.Resources; using Microsoft.Testing.Platform.CommandLine; using Microsoft.Testing.Platform.Extensions; using Microsoft.Testing.Platform.Extensions.CommandLine; diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/AzureDevOpsReporter.cs b/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/AzureDevOpsReporter.cs index 6f42dc4466..2167c1abac 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/AzureDevOpsReporter.cs +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/AzureDevOpsReporter.cs @@ -1,8 +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.AzureDevOps; +using Microsoft.Testing.Extensions.AzureDevOps.Resources; using Microsoft.Testing.Extensions.Reporting; -using Microsoft.Testing.Extensions.Reporting.Resources; using Microsoft.Testing.Platform; using Microsoft.Testing.Platform.CommandLine; using Microsoft.Testing.Platform.Extensions.Messages; @@ -65,10 +66,7 @@ public Task IsEnabledAsync() bool found = _commandLine.TryGetOptionArgumentList(AzureDevOpsCommandLineOptions.AzureDevOpsReportSeverity, out string[]? arguments); if (found && arguments?.Length > 0) { - if (string.Equals(arguments[0], "warning", StringComparison.OrdinalIgnoreCase)) - { - _severity = "warning"; - } + _severity = arguments[0].ToLowerInvariant(); } } @@ -82,36 +80,27 @@ public async Task ConsumeAsync(IDataProducer dataProducer, IData value, Cancella return; } - try + if (value is not TestNodeUpdateMessage nodeUpdateMessage) { - if (value is not TestNodeUpdateMessage nodeUpdateMessage) - { - return; - } - - TestNodeStateProperty nodeState = nodeUpdateMessage.TestNode.Properties.Single(); - - switch (nodeState) - { - case FailedTestNodeStateProperty failed: - await WriteExceptionAsync(failed.Explanation, failed.Exception); - break; - case ErrorTestNodeStateProperty error: - await WriteExceptionAsync(error.Explanation, error.Exception); - break; - case CancelledTestNodeStateProperty cancelled: - await WriteExceptionAsync(cancelled.Explanation, cancelled.Exception); - break; - case TimeoutTestNodeStateProperty timeout: - await WriteExceptionAsync(timeout.Explanation, timeout.Exception); - break; - } - return; } - catch (OperationCanceledException ex) when (ex.CancellationToken == cancellationToken) + + TestNodeStateProperty nodeState = nodeUpdateMessage.TestNode.Properties.Single(); + + switch (nodeState) { - // Do nothing, we're stopping + case FailedTestNodeStateProperty failed: + + break; + case ErrorTestNodeStateProperty error: + await WriteExceptionAsync(error.Explanation, error.Exception); + break; + case CancelledTestNodeStateProperty cancelled: + await WriteExceptionAsync(cancelled.Explanation, cancelled.Exception); + break; + case TimeoutTestNodeStateProperty timeout: + await WriteExceptionAsync(timeout.Explanation, timeout.Exception); + break; } return; diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/Microsoft.Testing.Extensions.AzureDevOps.csproj b/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/Microsoft.Testing.Extensions.AzureDevOps.csproj index a3ebb0c6bd..342819aa1b 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/Microsoft.Testing.Extensions.AzureDevOps.csproj +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/Microsoft.Testing.Extensions.AzureDevOps.csproj @@ -2,7 +2,10 @@ netstandard2.0;$(MicrosoftTestingTargetFrameworks) - Microsoft.Testing.Extensions.Reporting + + + 1.0.0 + alpha @@ -39,12 +42,6 @@ This package extends Microsoft Testing Platform to provide a Azure DevOps report - - - - - - diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/RootFinder.cs b/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/RootFinder.cs index b7989fe210..c51612f974 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/RootFinder.cs +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/RootFinder.cs @@ -1,7 +1,7 @@ // 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.TrxReport.Abstractions; +namespace Microsoft.Testing.Extensions.AzureDevOps; internal static class RootFinder { From 39267cf00c07304884f413b6ca03828ce6c097ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Jare=C5=A1?= Date: Thu, 20 Mar 2025 11:16:29 +0100 Subject: [PATCH 6/6] Normalize to forward slashes --- eng/Versions.props | 4 ++-- .../AzureDevOpsReporter.cs | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/eng/Versions.props b/eng/Versions.props index 496c0c00dc..898e64057a 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -1,9 +1,9 @@ - 3.9.0 + 3.10.0 - 1.7.0 + 1.8.0 preview diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/AzureDevOpsReporter.cs b/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/AzureDevOpsReporter.cs index 2167c1abac..ad3572d3c3 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/AzureDevOpsReporter.cs +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOps/AzureDevOpsReporter.cs @@ -90,7 +90,7 @@ public async Task ConsumeAsync(IDataProducer dataProducer, IData value, Cancella switch (nodeState) { case FailedTestNodeStateProperty failed: - + await WriteExceptionAsync(failed.Explanation, failed.Exception); break; case ErrorTestNodeStateProperty error: await WriteExceptionAsync(error.Explanation, error.Exception); @@ -131,10 +131,11 @@ private async Task WriteExceptionAsync(string? explanation, Exception? exception string root = RootFinder.Find(); string file = location.Value.File; string relativePath = file.StartsWith(root, StringComparison.CurrentCultureIgnoreCase) ? file.Substring(root.Length) : file; + string relativeNormalizedPath = relativePath.Replace('\\', '/'); string err = AzDoEscaper.Escape(message); - string line = $"##vso[task.logissue type={_severity};sourcepath={relativePath};linenumber={location.Value.LineNumber};columnnumber=1]{err}"; + string line = $"##vso[task.logissue type={_severity};sourcepath={relativeNormalizedPath};linenumber={location.Value.LineNumber};columnnumber=1]{err}"; await _outputDisplay.DisplayAsync(this, new FormattedTextOutputDeviceData(line)); } }