diff --git a/Directory.Packages.props b/Directory.Packages.props index 37f12c935a34..2e3b4ae1f740 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -63,8 +63,6 @@ - - diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml index 3cf2b8776cc8..94871caeefb2 100644 --- a/eng/Version.Details.xml +++ b/eng/Version.Details.xml @@ -634,11 +634,7 @@ https://github.com/dotnet/runtime e77011b31a3e5c47d931248a64b47f9b2d47853d - - https://github.com/microsoft/testfx - 53488cf463d33882113ca7a6fbbb6f93e06251df - - + https://github.com/microsoft/testfx 53488cf463d33882113ca7a6fbbb6f93e06251df diff --git a/eng/Versions.props b/eng/Versions.props index 42af24e86943..468b45f140e1 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -28,7 +28,6 @@ true 6.0.1 true - 1.6.0-preview.25059.14 30 diff --git a/src/Cli/dotnet/commands/dotnet-test/IPC/Models/TestResultMessages.cs b/src/Cli/dotnet/commands/dotnet-test/IPC/Models/TestResultMessages.cs index 5882717301ff..af9ee626604c 100644 --- a/src/Cli/dotnet/commands/dotnet-test/IPC/Models/TestResultMessages.cs +++ b/src/Cli/dotnet/commands/dotnet-test/IPC/Models/TestResultMessages.cs @@ -5,7 +5,9 @@ namespace Microsoft.DotNet.Tools.Test { internal sealed record SuccessfulTestResultMessage(string? Uid, string? DisplayName, byte? State, long? Duration, string? Reason, string? StandardOutput, string? ErrorOutput, string? SessionUid); - internal sealed record FailedTestResultMessage(string? Uid, string? DisplayName, byte? State, long? Duration, string? Reason, string? ErrorMessage, string? ErrorStackTrace, string? StandardOutput, string? ErrorOutput, string? SessionUid); + internal sealed record FailedTestResultMessage(string? Uid, string? DisplayName, byte? State, long? Duration, string? Reason, ExceptionMessage[]? Exceptions, string? StandardOutput, string? ErrorOutput, string? SessionUid); + + internal sealed record ExceptionMessage(string? ErrorMessage, string? ErrorType, string? StackTrace); internal sealed record TestResultMessages(string? ExecutionId, SuccessfulTestResultMessage[] SuccessfulTestMessages, FailedTestResultMessage[] FailedTestMessages) : IRequest; } diff --git a/src/Cli/dotnet/commands/dotnet-test/IPC/ObjectFieldIds.cs b/src/Cli/dotnet/commands/dotnet-test/IPC/ObjectFieldIds.cs index e7f720c96fbb..01d38f3d9ec2 100644 --- a/src/Cli/dotnet/commands/dotnet-test/IPC/ObjectFieldIds.cs +++ b/src/Cli/dotnet/commands/dotnet-test/IPC/ObjectFieldIds.cs @@ -85,13 +85,19 @@ internal static class FailedTestResultMessageFieldsId public const ushort State = 3; public const ushort Duration = 4; public const ushort Reason = 5; - public const ushort ErrorMessage = 6; - public const ushort ErrorStackTrace = 7; + public const ushort ExceptionMessageList = 6; public const ushort StandardOutput = 8; public const ushort ErrorOutput = 9; public const ushort SessionUid = 10; } + internal static class ExceptionMessageFieldsId + { + public const ushort ErrorMessage = 1; + public const ushort ErrorType = 2; + public const ushort StackTrace = 3; + } + internal static class FileArtifactMessagesFieldsId { public const int MessagesSerializerId = 7; diff --git a/src/Cli/dotnet/commands/dotnet-test/IPC/Serializers/TestResultMessagesSerializer.cs b/src/Cli/dotnet/commands/dotnet-test/IPC/Serializers/TestResultMessagesSerializer.cs index 9726bec16ff7..329c54e23b1c 100644 --- a/src/Cli/dotnet/commands/dotnet-test/IPC/Serializers/TestResultMessagesSerializer.cs +++ b/src/Cli/dotnet/commands/dotnet-test/IPC/Serializers/TestResultMessagesSerializer.cs @@ -217,8 +217,8 @@ private static List ReadFailedTestMessagesPayload(Strea int length = ReadInt(stream); for (int i = 0; i < length; i++) { - string? uid = null, displayName = null, reason = null, sessionUid = null, - errorMessage = null, errorStackTrace = null, standardOutput = null, errorOutput = null; + string? uid = null, displayName = null, reason = null, sessionUid = null, standardOutput = null, errorOutput = null; + List exceptionMessages = []; byte? state = null; long? duration = null; @@ -251,13 +251,44 @@ private static List ReadFailedTestMessagesPayload(Strea reason = ReadStringValue(stream, fieldSize); break; - case FailedTestResultMessageFieldsId.ErrorMessage: - errorMessage = ReadStringValue(stream, fieldSize); - break; + case FailedTestResultMessageFieldsId.ExceptionMessageList: + { + int length2 = ReadInt(stream); + for (int k = 0; k < length2; k++) + { - case FailedTestResultMessageFieldsId.ErrorStackTrace: - errorStackTrace = ReadStringValue(stream, fieldSize); - break; + int fieldCount2 = ReadShort(stream); + + string? errorMessage = null; + string? errorType = null; + string? stackTrace = null; + + for (int l = 0; l < fieldCount2; l++) + { + int fieldId2 = ReadShort(stream); + int fieldSize2 = ReadInt(stream); + + switch (fieldId2) + { + case ExceptionMessageFieldsId.ErrorMessage: + errorMessage = ReadStringValue(stream, fieldSize2); + break; + + case ExceptionMessageFieldsId.ErrorType: + errorType = ReadStringValue(stream, fieldSize2); + break; + + case ExceptionMessageFieldsId.StackTrace: + stackTrace = ReadStringValue(stream, fieldSize2); + break; + } + } + + exceptionMessages.Add(new ExceptionMessage(errorMessage, errorType, stackTrace)); + } + + break; + } case FailedTestResultMessageFieldsId.StandardOutput: standardOutput = ReadStringValue(stream, fieldSize); @@ -277,7 +308,7 @@ private static List ReadFailedTestMessagesPayload(Strea } } - failedTestResultMessages.Add(new FailedTestResultMessage(uid, displayName, state, duration, reason, errorMessage, errorStackTrace, standardOutput, errorOutput, sessionUid)); + failedTestResultMessages.Add(new FailedTestResultMessage(uid, displayName, state, duration, reason, exceptionMessages.ToArray(), standardOutput, errorOutput, sessionUid)); } return failedTestResultMessages; @@ -354,8 +385,7 @@ private static void WriteFailedTestMessagesPayload(Stream stream, FailedTestResu WriteField(stream, FailedTestResultMessageFieldsId.State, failedTestResultMessage.State); WriteField(stream, FailedTestResultMessageFieldsId.Duration, failedTestResultMessage.Duration); WriteField(stream, FailedTestResultMessageFieldsId.Reason, failedTestResultMessage.Reason); - WriteField(stream, FailedTestResultMessageFieldsId.ErrorMessage, failedTestResultMessage.ErrorMessage); - WriteField(stream, FailedTestResultMessageFieldsId.ErrorStackTrace, failedTestResultMessage.ErrorStackTrace); + WriteExceptionMessagesPayload(stream, failedTestResultMessage.Exceptions); WriteField(stream, FailedTestResultMessageFieldsId.StandardOutput, failedTestResultMessage.StandardOutput); WriteField(stream, FailedTestResultMessageFieldsId.ErrorOutput, failedTestResultMessage.ErrorOutput); WriteField(stream, FailedTestResultMessageFieldsId.SessionUid, failedTestResultMessage.SessionUid); @@ -366,6 +396,35 @@ private static void WriteFailedTestMessagesPayload(Stream stream, FailedTestResu WriteAtPosition(stream, (int)(stream.Position - before), before - sizeof(int)); } + private static void WriteExceptionMessagesPayload(Stream stream, ExceptionMessage[]? exceptionMessages) + { + if (exceptionMessages is null || exceptionMessages.Length == 0) + { + return; + } + + WriteShort(stream, FailedTestResultMessageFieldsId.ExceptionMessageList); + + // We will reserve an int (4 bytes) + // so that we fill the size later, once we write the payload + WriteInt(stream, 0); + + long before = stream.Position; + WriteInt(stream, exceptionMessages.Length); + foreach (ExceptionMessage exceptionMessage in exceptionMessages) + { + WriteShort(stream, GetFieldCount(exceptionMessage)); + + WriteField(stream, ExceptionMessageFieldsId.ErrorMessage, exceptionMessage.ErrorMessage); + WriteField(stream, ExceptionMessageFieldsId.ErrorType, exceptionMessage.ErrorType); + WriteField(stream, ExceptionMessageFieldsId.StackTrace, exceptionMessage.StackTrace); + } + + // NOTE: We are able to seek only if we are using a MemoryStream + // thus, the seek operation is fast as we are only changing the value of a property + WriteAtPosition(stream, (int)(stream.Position - before), before - sizeof(int)); + } + private static ushort GetFieldCount(TestResultMessages testResultMessages) => (ushort)((testResultMessages.ExecutionId is null ? 0 : 1) + (IsNullOrEmpty(testResultMessages.SuccessfulTestMessages) ? 0 : 1) + @@ -387,10 +446,14 @@ private static ushort GetFieldCount(FailedTestResultMessage failedTestResultMess (failedTestResultMessage.State is null ? 0 : 1) + (failedTestResultMessage.Duration is null ? 0 : 1) + (failedTestResultMessage.Reason is null ? 0 : 1) + - (failedTestResultMessage.ErrorMessage is null ? 0 : 1) + - (failedTestResultMessage.ErrorStackTrace is null ? 0 : 1) + + (IsNullOrEmpty(failedTestResultMessage.Exceptions) ? 0 : 1) + (failedTestResultMessage.StandardOutput is null ? 0 : 1) + (failedTestResultMessage.ErrorOutput is null ? 0 : 1) + (failedTestResultMessage.SessionUid is null ? 0 : 1)); + + private static ushort GetFieldCount(ExceptionMessage exceptionMessage) => + (ushort)((exceptionMessage.ErrorMessage is null ? 0 : 1) + + (exceptionMessage.ErrorType is null ? 0 : 1) + + (exceptionMessage.StackTrace is null ? 0 : 1)); } } diff --git a/src/Cli/dotnet/commands/dotnet-test/LocalizableStrings.resx b/src/Cli/dotnet/commands/dotnet-test/LocalizableStrings.resx index 1855e09972b5..df9c46f2a035 100644 --- a/src/Cli/dotnet/commands/dotnet-test/LocalizableStrings.resx +++ b/src/Cli/dotnet/commands/dotnet-test/LocalizableStrings.resx @@ -304,10 +304,10 @@ Examples: NAME="VALUE" - No serializer registered with ID '{0}' + No serializer registered with ID '{0}' - No serializer registered with type '{0}' + No serializer registered with type '{0}' The max number of test modules that can run in parallel. @@ -332,6 +332,114 @@ Examples: Test application(s) that support VSTest are not supported. + + Aborted + + + {0} tests running + + + and {0} more + + + Actual + + + canceled + + + Canceling the test session... + + + Console is already in batching mode. + Exception that is thrown when console is already collecting input into a batch (into a string builder), and code asks to enable batching mode again. + + + Discovered {0} tests in assembly + 0 is count, the sentence is followed by the path of the assebly + + + Discovering tests from + + + Exit code + + + Expected + + + Failed + + + failed + + + failed with {0} error(s) + + + failed with {0} error(s) and {1} warning(s) + + + failed with {0} warning(s) + + + For test + is followed by test name + + + from + from followed by a file name to point to the file from which test is originating + + + In process file artifacts produced: + + + Minimum expected tests policy violation, tests ran {0}, minimum expected {1} + {0}, {1} number of tests + + + Out of process file artifacts produced: + + + Passed + + + passed + + + Running tests from + + + skipped + + + at + at that is used for a stack frame location in a stack trace, is followed by a class and method name + + + in + in that is used in stack frame it is followed by file name + + + Error output + + + Standard output + + + Discovered {0} tests in {1} assemblies. + 0 is number of tests, 1 is count of assemblies + + + Discovered {0} tests. + 0 is number of tests + + + Test run summary: + + + Zero tests ran + Test runner not supported: {0}. diff --git a/src/Cli/dotnet/commands/dotnet-test/Models.cs b/src/Cli/dotnet/commands/dotnet-test/Models.cs index 9f18391810c6..17ca6a4261ec 100644 --- a/src/Cli/dotnet/commands/dotnet-test/Models.cs +++ b/src/Cli/dotnet/commands/dotnet-test/Models.cs @@ -13,7 +13,9 @@ internal sealed record DiscoveredTest(string? Uid, string? DisplayName); internal sealed record SuccessfulTestResult(string? Uid, string? DisplayName, byte? State, long? Duration, string? Reason, string? StandardOutput, string? ErrorOutput, string? SessionUid); - internal sealed record FailedTestResult(string? Uid, string? DisplayName, byte? State, long? Duration, string? Reason, string? ErrorMessage, string? ErrorStackTrace, string? StandardOutput, string? ErrorOutput, string? SessionUid); + internal sealed record FailedTestResult(string? Uid, string? DisplayName, byte? State, long? Duration, string? Reason, FlatException[]? Exceptions, string? StandardOutput, string? ErrorOutput, string? SessionUid); + + internal sealed record FlatException(string? ErrorMessage, string? ErrorType, string? StackTrace); internal sealed record FileArtifact(string? FullPath, string? DisplayName, string? Description, string? TestUid, string? TestDisplayName, string? SessionUid); diff --git a/src/Cli/dotnet/commands/dotnet-test/Options.cs b/src/Cli/dotnet/commands/dotnet-test/Options.cs index 43fdc3696ff9..598e3cb7d722 100644 --- a/src/Cli/dotnet/commands/dotnet-test/Options.cs +++ b/src/Cli/dotnet/commands/dotnet-test/Options.cs @@ -3,7 +3,7 @@ namespace Microsoft.DotNet.Cli { - internal record BuildConfigurationOptions(bool HasNoRestore, bool HasNoBuild, string Configuration, string Architecture); + internal record BuildConfigurationOptions(bool HasNoRestore, bool HasNoBuild, bool HasListTests, string Configuration, string Architecture); internal record BuildPathsOptions(string ProjectPath, string SolutionPath, string DirectoryPath); } diff --git a/src/Cli/dotnet/commands/dotnet-test/Terminal/AnsiCodes.cs b/src/Cli/dotnet/commands/dotnet-test/Terminal/AnsiCodes.cs new file mode 100644 index 000000000000..8e9543c9dbe3 --- /dev/null +++ b/src/Cli/dotnet/commands/dotnet-test/Terminal/AnsiCodes.cs @@ -0,0 +1,144 @@ +// 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.OutputDevice.Terminal; + +/// +/// A collection of standard ANSI/VT100 control codes. +/// +internal static class AnsiCodes +{ + /// + /// Escape character. + /// + public const string Esc = "\x1b"; + + /// + /// The control sequence introducer. + /// + public const string CSI = $"{Esc}["; + + /// + /// Select graphic rendition. + /// + /// + /// Print color-code to change text color. + /// + public const string SetColor = "m"; + + /// + /// Select graphic rendition - set bold mode. + /// + /// + /// Print to change text to bold. + /// + public const string SetBold = "1m"; + + /// + /// A shortcut to reset color back to normal. + /// + public const string SetDefaultColor = CSI + "m"; + + /// + /// Non-xterm extension to render a hyperlink. + /// + /// + /// Print urltext to render a hyperlink. + /// + public const string LinkPrefix = $"{Esc}]8;;"; + + /// + /// . + /// + public const string LinkInfix = $"{Esc}\\"; + + /// + /// . + /// + public const string LinkSuffix = $"{Esc}]8;;{Esc}\\"; + + /// + /// Moves up the specified number of lines and puts cursor at the beginning of the line. + /// + /// + /// Print N to move N lines up. + /// + public const string MoveUpToLineStart = "F"; + + /// + /// Moves forward (to the right) the specified number of characters. + /// + /// + /// Print N to move N characters forward. + /// + public const string MoveForward = "C"; + + /// + /// Moves backward (to the left) the specified number of characters. + /// + /// + /// Print N to move N characters backward. + /// + public const string MoveBackward = "D"; + + /// + /// Clears everything from cursor to end of screen. + /// + /// + /// Print to clear. + /// + public const string EraseInDisplay = "J"; + + /// + /// Clears everything from cursor to the end of the current line. + /// + /// + /// Print to clear. + /// + public const string EraseInLine = "K"; + + /// + /// Hides the cursor. + /// + public const string HideCursor = $"{Esc}[?25l"; + + /// + /// Shows/restores the cursor. + /// + public const string ShowCursor = $"{Esc}[?25h"; + + /// + /// Set progress state to a busy spinner.
+ /// Note: this code works only on ConEmu terminals, and conflicts with push a notification code on iTerm2. + ///
+ /// + /// ConEmu specific OSC codes.
+ /// iTerm2 proprietary escape codes. + ///
+ public const string SetBusySpinner = $"{Esc}]9;4;3;{Esc}\\"; + + /// + /// Remove progress state, restoring taskbar status to normal.
+ /// Note: this code works only on ConEmu terminals, and conflicts with push a notification code on iTerm2. + ///
+ /// + /// ConEmu specific OSC codes.
+ /// iTerm2 proprietary escape codes. + ///
+ public const string RemoveBusySpinner = $"{Esc}]9;4;0;{Esc}\\"; + + public static string Colorize(string? s, TerminalColor color) + => String.IsNullOrWhiteSpace(s) ? s ?? string.Empty : $"{CSI}{(int)color}{SetColor}{s}{SetDefaultColor}"; + + public static string MakeBold(string? s) + => String.IsNullOrWhiteSpace(s) ? s ?? string.Empty : $"{CSI}{SetBold}{s}{SetDefaultColor}"; + + public static string MoveCursorBackward(int count) => $"{CSI}{count}{MoveBackward}"; + + /// + /// Moves cursor to the specified column, or the rightmost column if is greater than the width of the terminal. + /// + /// Column index. + /// Control codes to set the desired position. + public static string SetCursorHorizontal(int column) => $"{CSI}{column}G"; +} diff --git a/src/Cli/dotnet/commands/dotnet-test/Terminal/AnsiDetector.cs b/src/Cli/dotnet/commands/dotnet-test/Terminal/AnsiDetector.cs new file mode 100644 index 000000000000..3cb6c139e71a --- /dev/null +++ b/src/Cli/dotnet/commands/dotnet-test/Terminal/AnsiDetector.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +// Portions of the code in this file were ported from the spectre.console by Patrik Svensson, Phil Scott, Nils Andresen +// https://github.com/spectreconsole/spectre.console/blob/main/src/Spectre.Console/Internal/Backends/Ansi/AnsiDetector.cs +// and from the supports-ansi project by Qingrong Ke +// https://github.com/keqingrong/supports-ansi/blob/master/index.js + +using System.Text.RegularExpressions; + +namespace Microsoft.Testing.Platform.OutputDevice.Terminal; + +/// +/// Works together with the to figure out if the current console is capable of using ANSI output codes. +/// +internal static class AnsiDetector +{ + private static readonly Regex[] TerminalsRegexes = + { + new("^xterm"), // xterm, PuTTY, Mintty + new("^rxvt"), // RXVT + new("^(?!eterm-color).*eterm.*"), // Accepts eterm, but not eterm-color, which does not support moving the cursor, see #9950. + new("^screen"), // GNU screen, tmux + new("tmux"), // tmux + new("^vt100"), // DEC VT series + new("^vt102"), // DEC VT series + new("^vt220"), // DEC VT series + new("^vt320"), // DEC VT series + new("ansi"), // ANSI + new("scoansi"), // SCO ANSI + new("cygwin"), // Cygwin, MinGW + new("linux"), // Linux console + new("konsole"), // Konsole + new("bvterm"), // Bitvise SSH Client + new("^st-256color"), // Suckless Simple Terminal, st + new("alacritty"), // Alacritty + }; + + public static bool IsAnsiSupported(string? termType) + => !String.IsNullOrEmpty(termType) && TerminalsRegexes.Any(regex => regex.IsMatch(termType)); +} diff --git a/src/Cli/dotnet/commands/dotnet-test/Terminal/AnsiTerminal.cs b/src/Cli/dotnet/commands/dotnet-test/Terminal/AnsiTerminal.cs new file mode 100644 index 000000000000..a52a8b951d17 --- /dev/null +++ b/src/Cli/dotnet/commands/dotnet-test/Terminal/AnsiTerminal.cs @@ -0,0 +1,311 @@ +// 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.Globalization; +using Microsoft.Testing.Platform.Helpers; +using LocalizableStrings = Microsoft.DotNet.Tools.Test.LocalizableStrings; + +namespace Microsoft.Testing.Platform.OutputDevice.Terminal; + +/// +/// Terminal writer that is used when writing ANSI is allowed. It is capable of batching as many updates as possible and writing them at the end, +/// because the terminal is responsible for rendering the colors and control codes. +/// +internal sealed class AnsiTerminal : ITerminal +{ + /// + /// File extensions that we will link to directly, all other files + /// are linked to their directory, to avoid opening dlls, or executables. + /// + private static readonly string[] KnownFileExtensions = new string[] + { + // code files + ".cs", + ".vb", + ".fs", + // logs + ".log", + ".txt", + // reports + ".coverage", + ".ctrf", + ".html", + ".junit", + ".nunit", + ".trx", + ".xml", + ".xunit", + }; + + private readonly IConsole _console; + private readonly string? _baseDirectory; + private readonly bool _useBusyIndicator; + private readonly StringBuilder _stringBuilder = new(); + private bool _isBatching; + private AnsiTerminalTestProgressFrame _currentFrame = new(0, 0); + + public AnsiTerminal(IConsole console, string? baseDirectory) + { + _console = console; + _baseDirectory = baseDirectory ?? Directory.GetCurrentDirectory(); + + // Output ansi code to get spinner on top of a terminal, to indicate in-progress task. + // https://github.com/dotnet/msbuild/issues/8958: iTerm2 treats ;9 code to post a notification instead, so disable progress reporting on Mac. + _useBusyIndicator = !RuntimeInformation.IsOSPlatform(OSPlatform.OSX); + } + + public int Width + => _console.IsOutputRedirected ? int.MaxValue : _console.BufferWidth; + + public int Height + => _console.IsOutputRedirected ? int.MaxValue : _console.BufferHeight; + + public void Append(char value) + { + if (_isBatching) + { + _stringBuilder.Append(value); + } + else + { + _console.Write(value); + } + } + + public void Append(string value) + { + if (_isBatching) + { + _stringBuilder.Append(value); + } + else + { + _console.Write(value); + } + } + + public void AppendLine() + { + if (_isBatching) + { + _stringBuilder.AppendLine(); + } + else + { + _console.WriteLine(); + } + } + + public void AppendLine(string value) + { + if (_isBatching) + { + _stringBuilder.AppendLine(value); + } + else + { + _console.WriteLine(value); + } + } + + public void SetColor(TerminalColor color) + { + string setColor = $"{AnsiCodes.CSI}{(int)color}{AnsiCodes.SetColor}"; + if (_isBatching) + { + _stringBuilder.Append(setColor); + } + else + { + _console.Write(setColor); + } + } + + public void ResetColor() + { + string resetColor = AnsiCodes.SetDefaultColor; + if (_isBatching) + { + _stringBuilder.Append(resetColor); + } + else + { + _console.Write(resetColor); + } + } + + public void ShowCursor() + { + if (_isBatching) + { + _stringBuilder.Append(AnsiCodes.ShowCursor); + } + else + { + _console.Write(AnsiCodes.ShowCursor); + } + } + + public void HideCursor() + { + if (_isBatching) + { + _stringBuilder.Append(AnsiCodes.HideCursor); + } + else + { + _console.Write(AnsiCodes.HideCursor); + } + } + + public void StartUpdate() + { + if (_isBatching) + { + throw new InvalidOperationException(LocalizableStrings.ConsoleIsAlreadyInBatchingMode); + } + + _stringBuilder.Clear(); + _isBatching = true; + } + + public void StopUpdate() + { + _console.Write(_stringBuilder.ToString()); + _isBatching = false; + } + + public void AppendLink(string? path, int? lineNumber) + { + if (String.IsNullOrWhiteSpace(path)) + { + return; + } + + // For non code files, point to the directory, so we don't end up running the + // exe by clicking at the link. + string? extension = Path.GetExtension(path); + bool linkToFile = !String.IsNullOrWhiteSpace(extension) && KnownFileExtensions.Contains(extension); + + bool knownNonExistingFile = path.StartsWith("/_/", ignoreCase: false, CultureInfo.CurrentCulture); + + string linkPath = path; + if (!linkToFile) + { + try + { + linkPath = Path.GetDirectoryName(linkPath) ?? linkPath; + } + catch + { + // Ignore all GetDirectoryName errors. + } + } + + // If the output path is under the initial working directory, make the console output relative to that to save space. + if (_baseDirectory != null && path.StartsWith(_baseDirectory, FileUtilities.PathComparison)) + { + if (path.Length > _baseDirectory.Length + && (path[_baseDirectory.Length] == Path.DirectorySeparatorChar + || path[_baseDirectory.Length] == Path.AltDirectorySeparatorChar)) + { + path = path[(_baseDirectory.Length + 1)..]; + } + } + + if (lineNumber != null) + { + path += $":{lineNumber}"; + } + + if (knownNonExistingFile) + { + Append(path); + return; + } + + // Generates file:// schema url string which is better handled by various Terminal clients than raw folder name. + if (Uri.TryCreate(linkPath, UriKind.Absolute, out Uri? uri)) + { + // url.ToString() un-escapes the URL which is needed for our case file:// + linkPath = uri.ToString(); + } + + SetColor(TerminalColor.DarkGray); + Append(AnsiCodes.LinkPrefix); + Append(linkPath); + Append(AnsiCodes.LinkInfix); + Append(path); + Append(AnsiCodes.LinkSuffix); + ResetColor(); + } + + public void MoveCursorUp(int lineCount) + { + string moveCursor = $"{AnsiCodes.CSI}{lineCount}{AnsiCodes.MoveUpToLineStart}"; + if (_isBatching) + { + _stringBuilder.AppendLine(moveCursor); + } + else + { + _console.WriteLine(moveCursor); + } + } + + public void SetCursorHorizontal(int position) + { + string setCursor = AnsiCodes.SetCursorHorizontal(position); + if (_isBatching) + { + _stringBuilder.Append(setCursor); + } + else + { + _console.Write(setCursor); + } + } + + /// + /// Erases the previously printed live node output. + /// + public void EraseProgress() + { + if (_currentFrame.RenderedLines == null || _currentFrame.RenderedLines.Count == 0) + { + return; + } + + AppendLine($"{AnsiCodes.CSI}{_currentFrame.RenderedLines.Count + 2}{AnsiCodes.MoveUpToLineStart}"); + Append($"{AnsiCodes.CSI}{AnsiCodes.EraseInDisplay}"); + _currentFrame.Clear(); + } + + public void RenderProgress(TestProgressState?[] progress) + { + AnsiTerminalTestProgressFrame newFrame = new(Width, Height); + newFrame.Render(_currentFrame, progress, terminal: this); + + _currentFrame = newFrame; + } + + public void StartBusyIndicator() + { + if (_useBusyIndicator) + { + Append(AnsiCodes.SetBusySpinner); + } + + HideCursor(); + } + + public void StopBusyIndicator() + { + if (_useBusyIndicator) + { + Append(AnsiCodes.RemoveBusySpinner); + } + + ShowCursor(); + } +} diff --git a/src/Cli/dotnet/commands/dotnet-test/Terminal/AnsiTerminalTestProgressFrame.cs b/src/Cli/dotnet/commands/dotnet-test/Terminal/AnsiTerminalTestProgressFrame.cs new file mode 100644 index 000000000000..35cd1f7e3813 --- /dev/null +++ b/src/Cli/dotnet/commands/dotnet-test/Terminal/AnsiTerminalTestProgressFrame.cs @@ -0,0 +1,352 @@ +// 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.Globalization; + +namespace Microsoft.Testing.Platform.OutputDevice.Terminal; + +/// +/// Captures that was rendered to screen, so we can only partially update the screen on next update. +/// +internal sealed class AnsiTerminalTestProgressFrame +{ + private const int MaxColumn = 250; + + public int Width { get; } + + public int Height { get; } + + public List? RenderedLines { get; set; } + + public AnsiTerminalTestProgressFrame(int width, int height) + { + Width = Math.Min(width, MaxColumn); + Height = height; + } + + public void AppendTestWorkerProgress(TestProgressState progress, RenderedProgressItem currentLine, AnsiTerminal terminal) + { + string durationString = HumanReadableDurationFormatter.Render(progress.Stopwatch.Elapsed); + + currentLine.RenderedDurationLength = durationString.Length; + + int nonReservedWidth = Width - (durationString.Length + 2); + + int passed = progress.PassedTests; + int failed = progress.FailedTests; + int skipped = progress.SkippedTests; + int charsTaken = 0; + + terminal.Append('['); + charsTaken++; + terminal.SetColor(TerminalColor.Green); + terminal.Append('✓'); + charsTaken++; + string passedText = passed.ToString(CultureInfo.CurrentCulture); + terminal.Append(passedText); + charsTaken += passedText.Length; + terminal.ResetColor(); + + terminal.Append('/'); + charsTaken++; + + terminal.SetColor(TerminalColor.Red); + terminal.Append('x'); + charsTaken++; + string failedText = failed.ToString(CultureInfo.CurrentCulture); + terminal.Append(failedText); + charsTaken += failedText.Length; + terminal.ResetColor(); + + terminal.Append('/'); + charsTaken++; + + terminal.SetColor(TerminalColor.Yellow); + terminal.Append('↓'); + charsTaken++; + string skippedText = skipped.ToString(CultureInfo.CurrentCulture); + terminal.Append(skippedText); + charsTaken += skippedText.Length; + terminal.ResetColor(); + terminal.Append(']'); + charsTaken++; + + terminal.Append(' '); + charsTaken++; + AppendToWidth(terminal, progress.AssemblyName, nonReservedWidth, ref charsTaken); + + if (charsTaken < nonReservedWidth && (progress.TargetFramework != null || progress.Architecture != null)) + { + int lengthNeeded = 0; + + lengthNeeded++; // for '(' + if (progress.TargetFramework != null) + { + lengthNeeded += progress.TargetFramework.Length; + if (progress.Architecture != null) + { + lengthNeeded++; // for '|' + } + } + + if (progress.Architecture != null) + { + lengthNeeded += progress.Architecture.Length; + } + + lengthNeeded++; // for ')' + + if ((charsTaken + lengthNeeded) < nonReservedWidth) + { + terminal.Append(" ("); + if (progress.TargetFramework != null) + { + terminal.Append(progress.TargetFramework); + if (progress.Architecture != null) + { + terminal.Append('|'); + } + } + + if (progress.Architecture != null) + { + terminal.Append(progress.Architecture); + } + + terminal.Append(')'); + } + } + + terminal.SetCursorHorizontal(Width - durationString.Length); + terminal.Append(durationString); + } + + public void AppendTestWorkerDetail(TestDetailState detail, RenderedProgressItem currentLine, AnsiTerminal terminal) + { + string durationString = HumanReadableDurationFormatter.Render(detail.Stopwatch?.Elapsed); + + currentLine.RenderedDurationLength = durationString.Length; + + int nonReservedWidth = Width - (durationString.Length + 2); + int charsTaken = 0; + + terminal.Append(" "); + charsTaken += 2; + + AppendToWidth(terminal, detail.Text, nonReservedWidth, ref charsTaken); + + terminal.SetCursorHorizontal(Width - durationString.Length); + terminal.Append(durationString); + } + + private static void AppendToWidth(AnsiTerminal terminal, string text, int width, ref int charsTaken) + { + if (charsTaken + text.Length < width) + { + terminal.Append(text); + charsTaken += text.Length; + } + else + { + terminal.Append("..."); + charsTaken += 3; + if (charsTaken < width) + { + int charsToTake = width - charsTaken; + string cutText = text[^charsToTake..]; + terminal.Append(cutText); + charsTaken += charsToTake; + } + } + } + + /// + /// Render VT100 string to update from current to next frame. + /// + public void Render(AnsiTerminalTestProgressFrame previousFrame, TestProgressState?[] progress, AnsiTerminal terminal) + { + // Clear everything if Terminal width or height have changed. + if (Width != previousFrame.Width || Height != previousFrame.Height) + { + terminal.EraseProgress(); + } + + // At the end of the terminal we're going to print the live progress. + // We re-render this progress by moving the cursor to the beginning of the previous progress + // and then overwriting the lines that have changed. + // The assumption we do here is that: + // - Each rendered line is a single line, i.e. a single detail cannot span multiple lines. + // - Each rendered detail can be tracked via a unique ID and version, so that we can + // quickly determine if the detail has changed since the last render. + + // Don't go up if we did not render any lines in previous frame or we already cleared them. + if (previousFrame.RenderedLines != null && previousFrame.RenderedLines.Count > 0) + { + // Move cursor back to 1st line of progress. + // + 2 because we output and empty line right below. + terminal.MoveCursorUp(previousFrame.RenderedLines.Count + 2); + } + + // When there is nothing to render, don't write empty lines, e.g. when we start the test run, and then we kick off build + // in dotnet test, there is a long pause where we have no assemblies and no test results (yet). + if (progress.Length > 0) + { + terminal.AppendLine(); + } + + int i = 0; + RenderedLines = new List(progress.Length * 2); + List progresses = GenerateLinesToRender(progress); + + foreach (object item in progresses) + { + if (previousFrame.RenderedLines != null && previousFrame.RenderedLines.Count > i) + { + if (item is TestProgressState progressItem) + { + var currentLine = new RenderedProgressItem(progressItem.Id, progressItem.Version); + RenderedLines.Add(currentLine); + + // We have a line that was rendered previously, compare it and decide how to render. + RenderedProgressItem previouslyRenderedLine = previousFrame.RenderedLines[i]; + if (previouslyRenderedLine.ProgressId == progressItem.Id && false) + { + // This is the same progress item and it was not updated since we rendered it, only update the timestamp if possible to avoid flicker. + string durationString = HumanReadableDurationFormatter.Render(progressItem.Stopwatch.Elapsed); + + if (previouslyRenderedLine.RenderedDurationLength == durationString.Length) + { + // Duration is the same length rewrite just it. + terminal.SetCursorHorizontal(MaxColumn); + terminal.Append($"{AnsiCodes.SetCursorHorizontal(MaxColumn)}{AnsiCodes.MoveCursorBackward(durationString.Length)}{durationString}"); + currentLine.RenderedDurationLength = durationString.Length; + } + else + { + // Duration is not the same length (it is longer because time moves only forward), we need to re-render the whole line + // to avoid writing the duration over the last portion of text: my.dll (1s) -> my.d (1m 1s) + terminal.Append($"{AnsiCodes.CSI}{AnsiCodes.EraseInLine}"); + AppendTestWorkerProgress(progressItem, currentLine, terminal); + } + } + else + { + // These lines are different or the line was updated. Render the whole line. + terminal.Append($"{AnsiCodes.CSI}{AnsiCodes.EraseInLine}"); + AppendTestWorkerProgress(progressItem, currentLine, terminal); + } + } + + if (item is TestDetailState detailItem) + { + var currentLine = new RenderedProgressItem(detailItem.Id, detailItem.Version); + RenderedLines.Add(currentLine); + + // We have a line that was rendered previously, compare it and decide how to render. + RenderedProgressItem previouslyRenderedLine = previousFrame.RenderedLines[i]; + if (previouslyRenderedLine.ProgressId == detailItem.Id && previouslyRenderedLine.ProgressVersion == detailItem.Version) + { + // This is the same progress item and it was not updated since we rendered it, only update the timestamp if possible to avoid flicker. + string durationString = HumanReadableDurationFormatter.Render(detailItem.Stopwatch?.Elapsed); + + if (previouslyRenderedLine.RenderedDurationLength == durationString.Length) + { + // Duration is the same length rewrite just it. + terminal.SetCursorHorizontal(MaxColumn); + terminal.Append($"{AnsiCodes.SetCursorHorizontal(MaxColumn)}{AnsiCodes.MoveCursorBackward(durationString.Length)}{durationString}"); + currentLine.RenderedDurationLength = durationString.Length; + } + else + { + // Duration is not the same length (it is longer because time moves only forward), we need to re-render the whole line + // to avoid writing the duration over the last portion of text: my.dll (1s) -> my.d (1m 1s) + terminal.Append($"{AnsiCodes.CSI}{AnsiCodes.EraseInLine}"); + AppendTestWorkerDetail(detailItem, currentLine, terminal); + } + } + else + { + // These lines are different or the line was updated. Render the whole line. + terminal.Append($"{AnsiCodes.CSI}{AnsiCodes.EraseInLine}"); + AppendTestWorkerDetail(detailItem, currentLine, terminal); + } + } + } + else + { + // We are rendering more lines than we rendered in previous frame + if (item is TestProgressState progressItem) + { + var currentLine = new RenderedProgressItem(progressItem.Id, progressItem.Version); + RenderedLines.Add(currentLine); + AppendTestWorkerProgress(progressItem, currentLine, terminal); + } + + if (item is TestDetailState detailItem) + { + var currentLine = new RenderedProgressItem(detailItem.Id, detailItem.Version); + RenderedLines.Add(currentLine); + AppendTestWorkerDetail(detailItem, currentLine, terminal); + } + } + + // This makes the progress not stick to the last line on the command line, which is + // not what I would prefer. But also if someone writes to console, the message will + // start at the beginning of the new line. Not after the progress bar that is kept on screen. + terminal.AppendLine(); + } + + // We rendered more lines in previous frame. Clear them. + if (previousFrame.RenderedLines != null && i < previousFrame.RenderedLines.Count) + { + terminal.Append($"{AnsiCodes.CSI}{AnsiCodes.EraseInDisplay}"); + } + } + + private List GenerateLinesToRender(TestProgressState?[] progress) + { + var linesToRender = new List(progress.Length); + + // Note: We want to render the list of active tests, but this can easily fill up the full screen. + // As such, we should balance the number of active tests shown per project. + // We do this by distributing the remaining lines for each projects. + TestProgressState[] progressItems = progress.OfType().ToArray(); + int linesToDistribute = (int)(Height * 0.7) - 1 - progressItems.Length; + var detailItems = new IEnumerable[progressItems.Length]; + IEnumerable sortedItemsIndices = Enumerable.Range(0, progressItems.Length).OrderBy(i => progressItems[i].TestNodeResultsState?.Count ?? 0); + + foreach (int sortedItemIndex in sortedItemsIndices) + { + detailItems[sortedItemIndex] = progressItems[sortedItemIndex].TestNodeResultsState?.GetRunningTasks( + linesToDistribute / progressItems.Length) + ?? Array.Empty(); + } + + for (int progressI = 0; progressI < progressItems.Length; progressI++) + { + linesToRender.Add(progressItems[progressI]); + linesToRender.AddRange(detailItems[progressI]); + } + + return linesToRender; + } + + public void Clear() => RenderedLines?.Clear(); + + internal sealed class RenderedProgressItem + { + public RenderedProgressItem(long id, long version) + { + ProgressId = id; + ProgressVersion = version; + } + + public long ProgressId { get; } + + public long ProgressVersion { get; } + + public int RenderedHeight { get; set; } + + public int RenderedDurationLength { get; set; } + } +} diff --git a/src/Cli/dotnet/commands/dotnet-test/Terminal/ErrorMessage.cs b/src/Cli/dotnet/commands/dotnet-test/Terminal/ErrorMessage.cs new file mode 100644 index 000000000000..f4e41aa43efa --- /dev/null +++ b/src/Cli/dotnet/commands/dotnet-test/Terminal/ErrorMessage.cs @@ -0,0 +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. + +namespace Microsoft.Testing.Platform.OutputDevice.Terminal; + +/// +/// An error message that was sent to output during the build. +/// +internal sealed record ErrorMessage(string Text) : IProgressMessage; diff --git a/src/Cli/dotnet/commands/dotnet-test/Terminal/ExceptionFlattener.cs b/src/Cli/dotnet/commands/dotnet-test/Terminal/ExceptionFlattener.cs new file mode 100644 index 000000000000..2578e0bada68 --- /dev/null +++ b/src/Cli/dotnet/commands/dotnet-test/Terminal/ExceptionFlattener.cs @@ -0,0 +1,52 @@ +// 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.OutputDevice.Terminal; + +internal sealed class ExceptionFlattener +{ + internal static FlatException[] Flatten(string? errorMessage, Exception? exception) + { + if (errorMessage is null && exception is null) + { + return Array.Empty(); + } + + string? message = !String.IsNullOrWhiteSpace(errorMessage) ? errorMessage : exception?.Message; + string? type = exception?.GetType().FullName; + string? stackTrace = exception?.StackTrace; + var flatException = new FlatException(message, type, stackTrace); + + List flatExceptions = new() + { + flatException, + }; + + // Add all inner exceptions. This will flatten top level AggregateExceptions, + // and all AggregateExceptions that are directly in AggregateExceptions, but won't expand + // AggregateExceptions that are in non-aggregate exception inner exceptions. + IEnumerable aggregateExceptions = exception switch + { + AggregateException aggregate => aggregate.Flatten().InnerExceptions, + _ => [exception?.InnerException], + }; + + foreach (Exception? aggregate in aggregateExceptions) + { + Exception? currentException = aggregate; + while (currentException is not null) + { + flatExceptions.Add(new FlatException( + aggregate?.Message, + aggregate?.GetType().FullName, + aggregate?.StackTrace)); + + currentException = currentException.InnerException; + } + } + + return flatExceptions.ToArray(); + } +} + +internal sealed record FlatException(string? ErrorMessage, string? ErrorType, string? StackTrace); diff --git a/src/Cli/dotnet/commands/dotnet-test/Terminal/FileUtilities.cs b/src/Cli/dotnet/commands/dotnet-test/Terminal/FileUtilities.cs new file mode 100644 index 000000000000..41711363117b --- /dev/null +++ b/src/Cli/dotnet/commands/dotnet-test/Terminal/FileUtilities.cs @@ -0,0 +1,37 @@ +// 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; + +namespace Microsoft.Testing.Platform.OutputDevice.Terminal; + +internal static class FileUtilities +{ + internal static readonly StringComparison PathComparison = GetIsFileSystemCaseSensitive() ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase; + + internal static readonly StringComparer PathComparer = GetIsFileSystemCaseSensitive() ? StringComparer.Ordinal : StringComparer.OrdinalIgnoreCase; + + /// + /// Determines whether the file system is case sensitive. + /// Copied from https://github.com/dotnet/runtime/blob/73ba11f3015216b39cb866d9fb7d3d25e93489f2/src/libraries/Common/src/System/IO/PathInternal.CaseSensitivity.cs#L41-L59 . + /// + public static bool GetIsFileSystemCaseSensitive() + { + try + { + string pathWithUpperCase = Path.Combine(Path.GetTempPath(), "CASESENSITIVETEST" + Guid.NewGuid().ToString("N")); + using (new FileStream(pathWithUpperCase, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None, 0x1000, FileOptions.DeleteOnClose)) + { + string lowerCased = pathWithUpperCase.ToLowerInvariant(); + return !File.Exists(lowerCased); + } + } + catch (Exception exc) + { + // In case something goes terribly wrong, we don't want to fail just because + // of a casing test, so we assume case-insensitive-but-preserving. + Debug.Fail("Casing test failed: " + exc); + return false; + } + } +} diff --git a/src/Cli/dotnet/commands/dotnet-test/Terminal/HumanReadableDurationFormatter.cs b/src/Cli/dotnet/commands/dotnet-test/Terminal/HumanReadableDurationFormatter.cs new file mode 100644 index 000000000000..97a43b6a15fe --- /dev/null +++ b/src/Cli/dotnet/commands/dotnet-test/Terminal/HumanReadableDurationFormatter.cs @@ -0,0 +1,112 @@ +// 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.Globalization; + +namespace Microsoft.Testing.Platform.OutputDevice.Terminal; + +internal static class HumanReadableDurationFormatter +{ + public static void Append(ITerminal terminal, TimeSpan duration, bool wrapInParentheses = true) + { + bool hasParentValue = false; + + if (wrapInParentheses) + { + terminal.Append('('); + } + + if (duration.Days > 0) + { + terminal.Append($"{duration.Days}d"); + hasParentValue = true; + } + + if (duration.Hours > 0 || hasParentValue) + { + terminal.Append(GetFormattedPart(duration.Hours, hasParentValue, "h")); + hasParentValue = true; + } + + if (duration.Minutes > 0 || hasParentValue) + { + terminal.Append(GetFormattedPart(duration.Minutes, hasParentValue, "m")); + hasParentValue = true; + } + + if (duration.Seconds > 0 || hasParentValue) + { + terminal.Append(GetFormattedPart(duration.Seconds, hasParentValue, "s")); + hasParentValue = true; + } + + if (duration.Milliseconds >= 0 || hasParentValue) + { + terminal.Append(GetFormattedPart(duration.Milliseconds, hasParentValue, "ms", paddingWitdh: 3)); + } + + if (wrapInParentheses) + { + terminal.Append(')'); + } + } + + private static string GetFormattedPart(int value, bool hasParentValue, string suffix, int paddingWitdh = 2) + => $"{(hasParentValue ? " " : string.Empty)}{(hasParentValue ? value.ToString(CultureInfo.InvariantCulture).PadLeft(paddingWitdh, '0') : value.ToString(CultureInfo.InvariantCulture))}{suffix}"; + + public static string Render(TimeSpan? duration, bool wrapInParentheses = true, bool showMilliseconds = false) + { + if (duration is null) + { + return string.Empty; + } + + bool hasParentValue = false; + + var stringBuilder = new StringBuilder(); + + if (wrapInParentheses) + { + stringBuilder.Append('('); + } + + if (duration.Value.Days > 0) + { + stringBuilder.Append(CultureInfo.CurrentCulture, $"{duration.Value.Days}d"); + hasParentValue = true; + } + + if (duration.Value.Hours > 0 || hasParentValue) + { + stringBuilder.Append(GetFormattedPart(duration.Value.Hours, hasParentValue, "h")); + hasParentValue = true; + } + + if (duration.Value.Minutes > 0 || hasParentValue) + { + stringBuilder.Append(GetFormattedPart(duration.Value.Minutes, hasParentValue, "m")); + hasParentValue = true; + } + + if (duration.Value.Seconds > 0 || hasParentValue || !showMilliseconds) + { + stringBuilder.Append(GetFormattedPart(duration.Value.Seconds, hasParentValue, "s")); + hasParentValue = true; + } + + if (showMilliseconds) + { + if (duration.Value.Milliseconds >= 0 || hasParentValue) + { + stringBuilder.Append(GetFormattedPart(duration.Value.Milliseconds, hasParentValue, "ms", paddingWitdh: 3)); + } + } + + if (wrapInParentheses) + { + stringBuilder.Append(')'); + } + + return stringBuilder.ToString(); + } +} diff --git a/src/Cli/dotnet/commands/dotnet-test/Terminal/IColor.cs b/src/Cli/dotnet/commands/dotnet-test/Terminal/IColor.cs new file mode 100644 index 000000000000..bfd6173af166 --- /dev/null +++ b/src/Cli/dotnet/commands/dotnet-test/Terminal/IColor.cs @@ -0,0 +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. + +namespace Microsoft.Testing.Platform.OutputDevice; + +/// +/// Represents a color. +/// +public interface IColor; diff --git a/src/Cli/dotnet/commands/dotnet-test/Terminal/IConsole.cs b/src/Cli/dotnet/commands/dotnet-test/Terminal/IConsole.cs new file mode 100644 index 000000000000..742d16083972 --- /dev/null +++ b/src/Cli/dotnet/commands/dotnet-test/Terminal/IConsole.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. + +namespace Microsoft.Testing.Platform.Helpers; + +/// +/// Wraps the static System.Console to be isolatable in tests. +/// +internal interface IConsole +{ + event ConsoleCancelEventHandler? CancelKeyPress; + + public int BufferHeight { get; } + + public int BufferWidth { get; } + + public bool IsOutputRedirected { get; } + + void SetForegroundColor(ConsoleColor color); + + void SetBackgroundColor(ConsoleColor color); + + ConsoleColor GetForegroundColor(); + + ConsoleColor GetBackgroundColor(); + + void WriteLine(); + + void WriteLine(string? value); + + void WriteLine(object? value); + + void WriteLine(string format, object? arg0); + + void WriteLine(string format, object? arg0, object? arg1); + + void WriteLine(string format, object? arg0, object? arg1, object? arg2); + + void WriteLine(string format, object?[]? args); + + void Write(string format, object?[]? args); + + void Write(string? value); + + void Write(char value); + + void Clear(); +} diff --git a/src/Cli/dotnet/commands/dotnet-test/Terminal/IProgressMessage.cs b/src/Cli/dotnet/commands/dotnet-test/Terminal/IProgressMessage.cs new file mode 100644 index 000000000000..f8f331ab233c --- /dev/null +++ b/src/Cli/dotnet/commands/dotnet-test/Terminal/IProgressMessage.cs @@ -0,0 +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. + +namespace Microsoft.Testing.Platform.OutputDevice.Terminal; + +/// +/// Error or warning message that was sent to screen during the test run. +/// +internal interface IProgressMessage; diff --git a/src/Cli/dotnet/commands/dotnet-test/Terminal/IStopwatch.cs b/src/Cli/dotnet/commands/dotnet-test/Terminal/IStopwatch.cs new file mode 100644 index 000000000000..e85a0a5bd627 --- /dev/null +++ b/src/Cli/dotnet/commands/dotnet-test/Terminal/IStopwatch.cs @@ -0,0 +1,13 @@ +// 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 interface IStopwatch +{ + void Start(); + + void Stop(); + + TimeSpan Elapsed { get; } +} diff --git a/src/Cli/dotnet/commands/dotnet-test/Terminal/ITerminal.cs b/src/Cli/dotnet/commands/dotnet-test/Terminal/ITerminal.cs new file mode 100644 index 000000000000..8fe335e2a83a --- /dev/null +++ b/src/Cli/dotnet/commands/dotnet-test/Terminal/ITerminal.cs @@ -0,0 +1,44 @@ +// 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.OutputDevice.Terminal; + +/// +/// An ANSI or non-ANSI terminal that is capable of rendering the messages from . +/// +internal interface ITerminal +{ + int Width { get; } + + int Height { get; } + + void Append(char value); + + void Append(string value); + + void AppendLine(); + + void AppendLine(string value); + + void AppendLink(string path, int? lineNumber); + + void SetColor(TerminalColor color); + + void ResetColor(); + + void ShowCursor(); + + void HideCursor(); + + void StartUpdate(); + + void StopUpdate(); + + void EraseProgress(); + + void RenderProgress(TestProgressState?[] progress); + + void StartBusyIndicator(); + + void StopBusyIndicator(); +} diff --git a/src/Cli/dotnet/commands/dotnet-test/Terminal/NativeMethods.cs b/src/Cli/dotnet/commands/dotnet-test/Terminal/NativeMethods.cs new file mode 100644 index 000000000000..0bd220fd5055 --- /dev/null +++ b/src/Cli/dotnet/commands/dotnet-test/Terminal/NativeMethods.cs @@ -0,0 +1,122 @@ +// 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.OutputDevice.Terminal; + +internal static class NativeMethods +{ + internal const uint FILE_TYPE_CHAR = 0x0002; + internal const int STD_OUTPUT_HANDLE = -11; + internal const int STD_ERROR_HANDLE = -12; + internal const uint ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004; + + private static bool? s_isWindows; + + /// + /// Gets a value indicating whether we are running under some version of Windows. + /// + // TODO: [SupportedOSPlatformGuard("windows")] + internal static bool IsWindows + { + get + { + s_isWindows ??= RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + return s_isWindows.Value; + } + } + + internal static (bool AcceptAnsiColorCodes, bool OutputIsScreen, uint? OriginalConsoleMode) QueryIsScreenAndTryEnableAnsiColorCodes(StreamHandleType handleType = StreamHandleType.StdOut) + { + if (Console.IsOutputRedirected) + { + // There's no ANSI terminal support if console output is redirected. + return (AcceptAnsiColorCodes: false, OutputIsScreen: false, OriginalConsoleMode: null); + } + + bool acceptAnsiColorCodes = false; + bool outputIsScreen = false; + uint? originalConsoleMode = null; + if (IsWindows) + { + try + { + nint outputStream = GetStdHandle((int)handleType); + if (GetConsoleMode(outputStream, out uint consoleMode)) + { + if ((consoleMode & ENABLE_VIRTUAL_TERMINAL_PROCESSING) == ENABLE_VIRTUAL_TERMINAL_PROCESSING) + { + // Console is already in required state. + acceptAnsiColorCodes = true; + } + else + { + originalConsoleMode = consoleMode; + consoleMode |= ENABLE_VIRTUAL_TERMINAL_PROCESSING; + if (SetConsoleMode(outputStream, consoleMode) && GetConsoleMode(outputStream, out consoleMode)) + { + // We only know if vt100 is supported if the previous call actually set the new flag, older + // systems ignore the setting. + acceptAnsiColorCodes = (consoleMode & ENABLE_VIRTUAL_TERMINAL_PROCESSING) == ENABLE_VIRTUAL_TERMINAL_PROCESSING; + } + } + + uint fileType = GetFileType(outputStream); + // The std out is a char type (LPT or Console). + outputIsScreen = fileType == FILE_TYPE_CHAR; + acceptAnsiColorCodes &= outputIsScreen; + } + } + catch + { + // In the unlikely case that the above fails we just ignore and continue. + } + } + else + { + // On posix OSes detect whether the terminal supports VT100 from the value of the TERM environment variable. +#pragma warning disable RS0030 // Do not use banned APIs + acceptAnsiColorCodes = AnsiDetector.IsAnsiSupported(Environment.GetEnvironmentVariable("TERM")); +#pragma warning restore RS0030 // Do not use banned APIs + // It wasn't redirected as tested above so we assume output is screen/console + outputIsScreen = true; + } + + return (acceptAnsiColorCodes, outputIsScreen, originalConsoleMode); + } + + internal static void RestoreConsoleMode(uint? originalConsoleMode, StreamHandleType handleType = StreamHandleType.StdOut) + { + if (IsWindows && originalConsoleMode is not null) + { + nint stdOut = GetStdHandle((int)handleType); + _ = SetConsoleMode(stdOut, originalConsoleMode.Value); + } + } + + [DllImport("kernel32.dll")] + // TODO: [SupportedOSPlatform("windows")] + internal static extern nint GetStdHandle(int nStdHandle); + + [DllImport("kernel32.dll")] + // TODO: [SupportedOSPlatform("windows")] + internal static extern uint GetFileType(nint hFile); + + internal enum StreamHandleType + { + /// + /// StdOut. + /// + StdOut = STD_OUTPUT_HANDLE, + + /// + /// StdError. + /// + StdErr = STD_ERROR_HANDLE, + } + + [DllImport("kernel32.dll")] + internal static extern bool GetConsoleMode(nint hConsoleHandle, out uint lpMode); + + [DllImport("kernel32.dll")] + internal static extern bool SetConsoleMode(nint hConsoleHandle, uint dwMode); +} diff --git a/src/Cli/dotnet/commands/dotnet-test/Terminal/NonAnsiTerminal.cs b/src/Cli/dotnet/commands/dotnet-test/Terminal/NonAnsiTerminal.cs new file mode 100644 index 000000000000..798b95e40df7 --- /dev/null +++ b/src/Cli/dotnet/commands/dotnet-test/Terminal/NonAnsiTerminal.cs @@ -0,0 +1,254 @@ +// 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.Globalization; +using Microsoft.Testing.Platform.Helpers; +using LocalizableStrings = Microsoft.DotNet.Tools.Test.LocalizableStrings; + +namespace Microsoft.Testing.Platform.OutputDevice.Terminal; + +/// +/// Non-ANSI terminal that writes text using the standard Console.Foreground color capabilities to stay compatible with +/// standard Windows command line, and other command lines that are not capable of ANSI, or when output is redirected. +/// +internal sealed class NonAnsiTerminal : ITerminal +{ + private readonly IConsole _console; + private readonly ConsoleColor _defaultForegroundColor; + private readonly StringBuilder _stringBuilder = new(); + private bool _isBatching; + + public NonAnsiTerminal(IConsole console) + { + _console = console; + _defaultForegroundColor = _console.GetForegroundColor(); + } + + public int Width => _console.IsOutputRedirected ? int.MaxValue : _console.BufferWidth; + + public int Height => _console.IsOutputRedirected ? int.MaxValue : _console.BufferHeight; + + public void Append(char value) + { + if (_isBatching) + { + _stringBuilder.Append(value); + } + else + { + _console.Write(value); + } + } + + public void Append(string value) + { + if (_isBatching) + { + _stringBuilder.Append(value); + } + else + { + _console.Write(value); + } + } + + public void AppendLine() + { + if (_isBatching) + { + _stringBuilder.AppendLine(); + } + else + { + _console.WriteLine(); + } + } + + public void AppendLine(string value) + { + if (_isBatching) + { + _stringBuilder.AppendLine(value); + } + else + { + _console.WriteLine(value); + } + } + + public void AppendLink(string path, int? lineNumber) + { + Append(path); + if (lineNumber.HasValue) + { + Append($":{lineNumber}"); + } + } + + public void SetColor(TerminalColor color) + { + if (_isBatching) + { + _console.Write(_stringBuilder.ToString()); + _stringBuilder.Clear(); + } + + _console.SetForegroundColor(ToConsoleColor(color)); + } + + public void ResetColor() + { + if (_isBatching) + { + _console.Write(_stringBuilder.ToString()); + _stringBuilder.Clear(); + } + + _console.SetForegroundColor(_defaultForegroundColor); + } + + public void ShowCursor() + { + // nop + } + + public void HideCursor() + { + // nop + } + + public void StartUpdate() + { + if (_isBatching) + { + throw new InvalidOperationException(LocalizableStrings.ConsoleIsAlreadyInBatchingMode); + } + + _stringBuilder.Clear(); + _isBatching = true; + } + + public void StopUpdate() + { + _console.Write(_stringBuilder.ToString()); + _isBatching = false; + } + + private ConsoleColor ToConsoleColor(TerminalColor color) => color switch + { + TerminalColor.Black => ConsoleColor.Black, + TerminalColor.DarkRed => ConsoleColor.DarkRed, + TerminalColor.DarkGreen => ConsoleColor.DarkGreen, + TerminalColor.DarkYellow => ConsoleColor.DarkYellow, + TerminalColor.DarkBlue => ConsoleColor.DarkBlue, + TerminalColor.DarkMagenta => ConsoleColor.DarkMagenta, + TerminalColor.DarkCyan => ConsoleColor.DarkCyan, + TerminalColor.Gray => ConsoleColor.White, + TerminalColor.Default => _defaultForegroundColor, + TerminalColor.DarkGray => ConsoleColor.Gray, + TerminalColor.Red => ConsoleColor.Red, + TerminalColor.Green => ConsoleColor.Green, + TerminalColor.Yellow => ConsoleColor.Yellow, + TerminalColor.Blue => ConsoleColor.Blue, + TerminalColor.Magenta => ConsoleColor.Magenta, + TerminalColor.Cyan => ConsoleColor.Cyan, + TerminalColor.White => ConsoleColor.White, + _ => _defaultForegroundColor, + }; + + public void EraseProgress() + { + // nop + } + + public void RenderProgress(TestProgressState?[] progress) + { + int count = 0; + foreach (TestProgressState? p in progress) + { + if (p == null) + { + continue; + } + + count++; + + string durationString = HumanReadableDurationFormatter.Render(p.Stopwatch.Elapsed); + + int passed = p.PassedTests; + int failed = p.FailedTests; + int skipped = p.SkippedTests; + + // Use just ascii here, so we don't put too many restrictions on fonts needing to + // properly show unicode, or logs being saved in particular encoding. + Append('['); + SetColor(TerminalColor.DarkGreen); + Append('+'); + Append(passed.ToString(CultureInfo.CurrentCulture)); + ResetColor(); + + Append('/'); + + SetColor(TerminalColor.DarkRed); + Append('x'); + Append(failed.ToString(CultureInfo.CurrentCulture)); + ResetColor(); + + Append('/'); + + SetColor(TerminalColor.DarkYellow); + Append('?'); + Append(skipped.ToString(CultureInfo.CurrentCulture)); + ResetColor(); + Append(']'); + + Append(' '); + Append(p.AssemblyName); + + if (p.TargetFramework != null || p.Architecture != null) + { + Append(" ("); + if (p.TargetFramework != null) + { + Append(p.TargetFramework); + Append('|'); + } + + if (p.Architecture != null) + { + Append(p.Architecture); + } + + Append(')'); + } + + TestDetailState? activeTest = p.TestNodeResultsState?.GetRunningTasks(1).FirstOrDefault(); + if (!String.IsNullOrWhiteSpace(activeTest?.Text)) + { + Append(" - "); + Append(activeTest.Text); + Append(' '); + } + + Append(durationString); + + AppendLine(); + } + + // Do not render empty lines when there is nothing to show. + if (count > 0) + { + AppendLine(); + } + } + + public void StartBusyIndicator() + { + // nop + } + + public void StopBusyIndicator() + { + // nop + } +} diff --git a/src/Cli/dotnet/commands/dotnet-test/Terminal/SystemConsole.cs b/src/Cli/dotnet/commands/dotnet-test/Terminal/SystemConsole.cs new file mode 100644 index 000000000000..996ac44b1142 --- /dev/null +++ b/src/Cli/dotnet/commands/dotnet-test/Terminal/SystemConsole.cs @@ -0,0 +1,212 @@ +// 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 sealed class SystemConsole : IConsole +{ + private const int WriteBufferSize = 256; + private static readonly StreamWriter CaptureConsoleOutWriter; + + /// + /// Gets the height of the buffer area. + /// + public int BufferHeight => Console.BufferHeight; + + /// + /// Gets the width of the buffer area. + /// + public int BufferWidth => Console.BufferWidth; + + /// + /// Gets a value indicating whether output has been redirected from the standard output stream. + /// + public bool IsOutputRedirected => Console.IsOutputRedirected; + + private bool _suppressOutput; + + static SystemConsole() => + // From https://github.com/dotnet/runtime/blob/main/src/libraries/System.Console/src/System/Console.cs#L236 + CaptureConsoleOutWriter = new StreamWriter( + stream: Console.OpenStandardOutput(), + encoding: Console.Out.Encoding, + bufferSize: WriteBufferSize, + leaveOpen: true) + { + AutoFlush = true, + }; + + // the following event does not make sense in the mobile scenarios, user cannot ctrl+c + // but can just kill the app in the device via a gesture + public event ConsoleCancelEventHandler? CancelKeyPress + { + add + { +#if NET8_0_OR_GREATER + if (RuntimeInformation.RuntimeIdentifier.Contains("ios") || + RuntimeInformation.RuntimeIdentifier.Contains("android")) + { + return; + } +#endif + +#pragma warning disable IDE0027 // Use expression body for accessor + Console.CancelKeyPress += value; +#pragma warning restore IDE0027 // Use expression body for accessor + } + + remove + { +#if NET8_0_OR_GREATER + if (RuntimeInformation.RuntimeIdentifier.Contains("ios") || + RuntimeInformation.RuntimeIdentifier.Contains("android")) + { + return; + } +#endif +#pragma warning disable IDE0027 // Use expression body for accessor + Console.CancelKeyPress -= value; +#pragma warning restore IDE0027 // Use expression body for accessor + } + } + + public void SuppressOutput() => _suppressOutput = true; + + public void WriteLine() + { + if (!_suppressOutput) + { + CaptureConsoleOutWriter.WriteLine(); + } + } + + public void WriteLine(string? value) + { + if (!_suppressOutput) + { + CaptureConsoleOutWriter.WriteLine(value); + } + } + + public void WriteLine(object? value) + { + if (!_suppressOutput) + { + CaptureConsoleOutWriter.WriteLine(value); + } + } + + public void WriteLine(string format, object? arg0) + { + if (!_suppressOutput) + { + CaptureConsoleOutWriter.WriteLine(format, arg0); + } + } + + public void WriteLine(string format, object? arg0, object? arg1) + { + if (!_suppressOutput) + { + CaptureConsoleOutWriter.WriteLine(format, arg0, arg1); + } + } + + public void WriteLine(string format, object? arg0, object? arg1, object? arg2) + { + if (!_suppressOutput) + { + CaptureConsoleOutWriter.WriteLine(format, arg0, arg1, arg2); + } + } + + public void WriteLine(string format, object?[]? args) + { + if (!_suppressOutput) + { + CaptureConsoleOutWriter.WriteLine(format, args!); + } + } + + public void Write(string format, object?[]? args) + { + if (!_suppressOutput) + { + CaptureConsoleOutWriter.Write(format, args!); + } + } + + public void Write(string? value) + { + if (!_suppressOutput) + { + CaptureConsoleOutWriter.Write(value); + } + } + + public void Write(char value) + { + if (!_suppressOutput) + { + CaptureConsoleOutWriter.Write(value); + } + } + + public void SetForegroundColor(ConsoleColor color) + { +#if NET8_0_OR_GREATER + if (RuntimeInformation.RuntimeIdentifier.Contains("ios") || + RuntimeInformation.RuntimeIdentifier.Contains("android")) + { + return; + } +#endif +#pragma warning disable IDE0022 // Use expression body for method + Console.ForegroundColor = color; +#pragma warning restore IDE0022 // Use expression body for method + } + + public void SetBackgroundColor(ConsoleColor color) + { +#if NET8_0_OR_GREATER + if (RuntimeInformation.RuntimeIdentifier.Contains("ios") || + RuntimeInformation.RuntimeIdentifier.Contains("android")) + { + return; + } +#endif +#pragma warning disable IDE0022 // Use expression body for method + Console.BackgroundColor = color; +#pragma warning restore IDE0022 // Use expression body for method + } + + public ConsoleColor GetForegroundColor() + { +#if NET8_0_OR_GREATER + if (RuntimeInformation.RuntimeIdentifier.Contains("ios") || + RuntimeInformation.RuntimeIdentifier.Contains("android")) + { + return ConsoleColor.Black; + } +#endif +#pragma warning disable IDE0022 // Use expression body for method + return Console.ForegroundColor; +#pragma warning restore IDE0022 // Use expression body for method + } + + public ConsoleColor GetBackgroundColor() + { +#if NET8_0_OR_GREATER + if (RuntimeInformation.RuntimeIdentifier.Contains("ios") || + RuntimeInformation.RuntimeIdentifier.Contains("android")) + { + return ConsoleColor.Black; + } +#endif +#pragma warning disable IDE0022 // Use expression body for method + return Console.BackgroundColor; +#pragma warning restore IDE0022 // Use expression body for method + } + + public void Clear() => Console.Clear(); +} diff --git a/src/Cli/dotnet/commands/dotnet-test/Terminal/SystemConsoleColor.cs b/src/Cli/dotnet/commands/dotnet-test/Terminal/SystemConsoleColor.cs new file mode 100644 index 000000000000..e59363aa915e --- /dev/null +++ b/src/Cli/dotnet/commands/dotnet-test/Terminal/SystemConsoleColor.cs @@ -0,0 +1,15 @@ +// 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.OutputDevice; + +/// +/// Represents a system console color. +/// +public sealed class SystemConsoleColor : IColor +{ + /// + /// Gets or inits the console color. + /// + public ConsoleColor ConsoleColor { get; init; } +} diff --git a/src/Cli/dotnet/commands/dotnet-test/Terminal/SystemStopwatch.cs b/src/Cli/dotnet/commands/dotnet-test/Terminal/SystemStopwatch.cs new file mode 100644 index 000000000000..210513d6093c --- /dev/null +++ b/src/Cli/dotnet/commands/dotnet-test/Terminal/SystemStopwatch.cs @@ -0,0 +1,25 @@ +// 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; + +namespace Microsoft.Testing.Platform.Helpers; + +internal sealed class SystemStopwatch : IStopwatch +{ + private readonly Stopwatch _stopwatch = new(); + + public TimeSpan Elapsed => _stopwatch.Elapsed; + + public void Start() => _stopwatch.Start(); + + public void Stop() => _stopwatch.Stop(); + + public static IStopwatch StartNew() + { + SystemStopwatch wallClockStopwatch = new(); + wallClockStopwatch.Start(); + + return wallClockStopwatch; + } +} diff --git a/src/Cli/dotnet/commands/dotnet-test/Terminal/TargetFrameworkParser.cs b/src/Cli/dotnet/commands/dotnet-test/Terminal/TargetFrameworkParser.cs new file mode 100644 index 000000000000..1e8e4ec7a5bb --- /dev/null +++ b/src/Cli/dotnet/commands/dotnet-test/Terminal/TargetFrameworkParser.cs @@ -0,0 +1,74 @@ +// 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.Globalization; + +namespace Microsoft.Testing.Platform.OutputDevice; + +internal static class TargetFrameworkParser +{ + public static string? GetShortTargetFramework(string? frameworkDescription) + { + if (frameworkDescription == null) + { + return null; + } + + // https://learn.microsoft.com/dotnet/api/system.runtime.interopservices.runtimeinformation.frameworkdescription + string netFramework = ".NET Framework"; + if (frameworkDescription.StartsWith(netFramework, ignoreCase: false, CultureInfo.InvariantCulture)) + { + // .NET Framework 4.7.2 + if (frameworkDescription.Length < (netFramework.Length + 6)) + { + return frameworkDescription; + } + + char major = frameworkDescription[netFramework.Length + 1]; + char minor = frameworkDescription[netFramework.Length + 3]; + char patch = frameworkDescription[netFramework.Length + 5]; + + if (major == '4' && minor == '6' && patch == '2') + { + return "net462"; + } + else if (major == '4' && minor == '7' && patch == '1') + { + return "net471"; + } + else if (major == '4' && minor == '7' && patch == '2') + { + return "net472"; + } + else if (major == '4' && minor == '8' && patch == '1') + { + return "net481"; + } + else + { + // Just return the first 2 numbers. + return $"net{major}{minor}"; + } + } + + string netCore = ".NET Core"; + if (frameworkDescription.StartsWith(netCore, ignoreCase: false, CultureInfo.InvariantCulture)) + { + // .NET Core 3.1 + return frameworkDescription.Length >= (netCore.Length + 4) + ? $"netcoreapp{frameworkDescription[netCore.Length + 1]}.{frameworkDescription[netCore.Length + 3]}" + : frameworkDescription; + } + + string net = ".NET"; + if (frameworkDescription.StartsWith(net, ignoreCase: false, CultureInfo.InvariantCulture)) + { + int firstDotInVersion = frameworkDescription.IndexOf('.', net.Length + 1); + return firstDotInVersion < 1 + ? frameworkDescription + : $"net{frameworkDescription.Substring(net.Length + 1, firstDotInVersion - net.Length - 1)}.{frameworkDescription[firstDotInVersion + 1]}"; + } + + return frameworkDescription; + } +} diff --git a/src/Cli/dotnet/commands/dotnet-test/Terminal/TerminalColor.cs b/src/Cli/dotnet/commands/dotnet-test/Terminal/TerminalColor.cs new file mode 100644 index 000000000000..1120f47962b4 --- /dev/null +++ b/src/Cli/dotnet/commands/dotnet-test/Terminal/TerminalColor.cs @@ -0,0 +1,95 @@ +// 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.OutputDevice.Terminal; + +/// +/// Enumerates the text colors supported by VT100 terminal. +/// +internal enum TerminalColor +{ + /// + /// Black. + /// + Black = 30, + + /// + /// DarkRed. + /// + DarkRed = 31, + + /// + /// DarkGreen. + /// + DarkGreen = 32, + + /// + /// DarkYellow. + /// + DarkYellow = 33, + + /// + /// DarkBlue. + /// + DarkBlue = 34, + + /// + /// DarkMagenta. + /// + DarkMagenta = 35, + + /// + /// DarkCyan. + /// + DarkCyan = 36, + + /// + /// Gray. This entry looks out of order, but in reality 37 is dark white, which is lighter than bright black = Dark Gray in Console colors. + /// + Gray = 37, + + /// + /// Default. + /// + Default = 39, + + /// + /// DarkGray. This entry looks out of order, but in reality 90 is bright black, which is darker than dark white = Gray in Console colors. + /// + DarkGray = 90, + + /// + /// Red. + /// + Red = 91, + + /// + /// Green. + /// + Green = 92, + + /// + /// Yellow. + /// + Yellow = 93, + + /// + /// Blue. + /// + Blue = 94, + + /// + /// Magenta. + /// + Magenta = 95, + + /// + /// Cyan. + /// + Cyan = 96, + + /// + /// White. + /// + White = 97, +} diff --git a/src/Cli/dotnet/commands/dotnet-test/Terminal/TerminalTestReporter.cs b/src/Cli/dotnet/commands/dotnet-test/Terminal/TerminalTestReporter.cs new file mode 100644 index 000000000000..38eda2d001cf --- /dev/null +++ b/src/Cli/dotnet/commands/dotnet-test/Terminal/TerminalTestReporter.cs @@ -0,0 +1,1019 @@ +// 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.Collections.Concurrent; +using System.Globalization; +using System.Text.RegularExpressions; +using Microsoft.Testing.Platform.Helpers; +using LocalizableStrings = Microsoft.DotNet.Tools.Test.LocalizableStrings; + +namespace Microsoft.Testing.Platform.OutputDevice.Terminal; + +/// +/// Terminal test reporter that outputs test progress and is capable of writing ANSI or non-ANSI output via the given terminal. +/// +internal sealed partial class TerminalTestReporter : IDisposable +{ + /// + /// The two directory separator characters to be passed to methods like . + /// + private static readonly string[] NewLineStrings = { "\r\n", "\n" }; + + internal const string SingleIndentation = " "; + + internal const string DoubleIndentation = $"{SingleIndentation}{SingleIndentation}"; + + internal Func CreateStopwatch { get; set; } = SystemStopwatch.StartNew; + + internal event EventHandler OnProgressStartUpdate + { + add => _terminalWithProgress.OnProgressStartUpdate += value; + remove => _terminalWithProgress.OnProgressStartUpdate -= value; + } + + internal event EventHandler OnProgressStopUpdate + { + add => _terminalWithProgress.OnProgressStopUpdate += value; + remove => _terminalWithProgress.OnProgressStopUpdate -= value; + } + + private readonly ConcurrentDictionary _assemblies = new(); + + private readonly List _artifacts = new(); + + private readonly TerminalTestReporterOptions _options; + + private readonly TestProgressStateAwareTerminal _terminalWithProgress; + + private readonly uint? _originalConsoleMode; + private bool _isDiscovery; + private DateTimeOffset? _testExecutionStartTime; + + private DateTimeOffset? _testExecutionEndTime; + + private int _buildErrorsCount; + + private bool _wasCancelled; + + 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; + + /// + /// Initializes a new instance of the class with custom terminal and manual refresh for testing. + /// + public TerminalTestReporter(IConsole console, TerminalTestReporterOptions options) + { + _options = options; + + Func showProgress = _options.ShowProgress; + TestProgressStateAwareTerminal terminalWithProgress; + + // When not writing to ANSI we write the progress to screen and leave it there so we don't want to write it more often than every few seconds. + int nonAnsiUpdateCadenceInMs = 3_000; + // When writing to ANSI we update the progress in place and it should look responsive so we update every half second, because we only show seconds on the screen, so it is good enough. + int ansiUpdateCadenceInMs = 500; + if (!_options.UseAnsi || _options.ForceAnsi is false) + { + terminalWithProgress = new TestProgressStateAwareTerminal(new NonAnsiTerminal(console), showProgress, writeProgressImmediatelyAfterOutput: false, updateEvery: nonAnsiUpdateCadenceInMs); + } + else + { + // Autodetect. + (bool consoleAcceptsAnsiCodes, bool _, uint? originalConsoleMode) = NativeMethods.QueryIsScreenAndTryEnableAnsiColorCodes(); + _originalConsoleMode = originalConsoleMode; + terminalWithProgress = consoleAcceptsAnsiCodes || _options.ForceAnsi is true + ? new TestProgressStateAwareTerminal(new AnsiTerminal(console, _options.BaseDirectory), showProgress, writeProgressImmediatelyAfterOutput: true, updateEvery: ansiUpdateCadenceInMs) + : new TestProgressStateAwareTerminal(new NonAnsiTerminal(console), showProgress, writeProgressImmediatelyAfterOutput: false, updateEvery: nonAnsiUpdateCadenceInMs); + } + + _terminalWithProgress = terminalWithProgress; + } + + public void TestExecutionStarted(DateTimeOffset testStartTime, int workerCount, bool isDiscovery) + { + _isDiscovery = isDiscovery; + _testExecutionStartTime = testStartTime; + _terminalWithProgress.StartShowingProgress(workerCount); + } + + public void AssemblyRunStarted(string assembly, string? targetFramework, string? architecture, string? executionId) + { + if (_options.ShowAssembly && _options.ShowAssemblyStartAndComplete) + { + _terminalWithProgress.WriteToTerminal(terminal => + { + terminal.Append(_isDiscovery ? LocalizableStrings.DiscoveringTestsFrom : LocalizableStrings.RunningTestsFrom); + terminal.Append(' '); + AppendAssemblyLinkTargetFrameworkAndArchitecture(terminal, assembly, targetFramework, architecture); + terminal.AppendLine(); + }); + } + + GetOrAddAssemblyRun(assembly, targetFramework, architecture, executionId); + } + + private TestProgressState GetOrAddAssemblyRun(string assembly, string? targetFramework, string? architecture, string? executionId) + { + string key = $"{assembly}|{targetFramework}|{architecture}|{executionId}"; + return _assemblies.GetOrAdd(key, _ => + { + IStopwatch sw = CreateStopwatch(); + var assemblyRun = new TestProgressState(Interlocked.Increment(ref _counter), assembly, targetFramework, architecture, sw); + int slotIndex = _terminalWithProgress.AddWorker(assemblyRun); + assemblyRun.SlotIndex = slotIndex; + + return assemblyRun; + }); + } + + public void TestExecutionCompleted(DateTimeOffset endTime) + { + _testExecutionEndTime = endTime; + _terminalWithProgress.StopShowingProgress(); + + _terminalWithProgress.WriteToTerminal(_isDiscovery ? AppendTestDiscoverySummary : AppendTestRunSummary); + + NativeMethods.RestoreConsoleMode(_originalConsoleMode); + _assemblies.Clear(); + _buildErrorsCount = 0; + _testExecutionStartTime = null; + _testExecutionEndTime = null; + } + + private void AppendTestRunSummary(ITerminal terminal) + { + terminal.AppendLine(); + + IEnumerable> artifactGroups = _artifacts.GroupBy(a => a.OutOfProcess); + if (artifactGroups.Any()) + { + terminal.AppendLine(); + } + + foreach (IGrouping artifactGroup in artifactGroups) + { + terminal.Append(SingleIndentation); + terminal.AppendLine(artifactGroup.Key ? LocalizableStrings.OutOfProcessArtifactsProduced : LocalizableStrings.InProcessArtifactsProduced); + foreach (TestRunArtifact artifact in artifactGroup) + { + terminal.Append(DoubleIndentation); + terminal.Append("- "); + if (!String.IsNullOrWhiteSpace(artifact.TestName)) + { + terminal.Append(LocalizableStrings.ForTest); + terminal.Append(" '"); + terminal.Append(artifact.TestName); + terminal.Append("': "); + } + + terminal.AppendLink(artifact.Path, lineNumber: null); + terminal.AppendLine(); + } + } + + int totalTests = _assemblies.Values.Sum(a => a.TotalTests); + int totalFailedTests = _assemblies.Values.Sum(a => a.FailedTests); + int totalSkippedTests = _assemblies.Values.Sum(a => a.SkippedTests); + + bool notEnoughTests = totalTests < _options.MinimumExpectedTests; + bool allTestsWereSkipped = totalTests == 0 || totalTests == totalSkippedTests; + bool anyTestFailed = totalFailedTests > 0; + bool runFailed = anyTestFailed || notEnoughTests || allTestsWereSkipped || _wasCancelled; + terminal.SetColor(runFailed ? TerminalColor.Red : TerminalColor.Green); + + terminal.Append(LocalizableStrings.TestRunSummary); + terminal.Append(' '); + + if (_wasCancelled) + { + terminal.Append(LocalizableStrings.Aborted); + } + else if (notEnoughTests) + { + terminal.Append(string.Format(CultureInfo.CurrentCulture, LocalizableStrings.MinimumExpectedTestsPolicyViolation, totalTests, _options.MinimumExpectedTests)); + } + else if (allTestsWereSkipped) + { + terminal.Append(LocalizableStrings.ZeroTestsRan); + } + else if (anyTestFailed) + { + terminal.Append(string.Format(CultureInfo.CurrentCulture, "{0}!", LocalizableStrings.Failed)); + } + else + { + terminal.Append(string.Format(CultureInfo.CurrentCulture, "{0}!", LocalizableStrings.Passed)); + } + + if (!_options.ShowAssembly && _assemblies.Count == 1) + { + TestProgressState testProgressState = _assemblies.Values.Single(); + terminal.SetColor(TerminalColor.DarkGray); + terminal.Append(" - "); + terminal.ResetColor(); + AppendAssemblyLinkTargetFrameworkAndArchitecture(terminal, testProgressState.Assembly, testProgressState.TargetFramework, testProgressState.Architecture); + } + + terminal.AppendLine(); + + if (_options.ShowAssembly && _assemblies.Count > 1) + { + foreach (TestProgressState assemblyRun in _assemblies.Values) + { + terminal.Append(SingleIndentation); + AppendAssemblySummary(assemblyRun, terminal); + terminal.AppendLine(); + } + + terminal.AppendLine(); + } + + int total = _assemblies.Values.Sum(t => t.TotalTests); + int failed = _assemblies.Values.Sum(t => t.FailedTests); + int passed = _assemblies.Values.Sum(t => t.PassedTests); + int skipped = _assemblies.Values.Sum(t => t.SkippedTests); + TimeSpan runDuration = _testExecutionStartTime != null && _testExecutionEndTime != null ? (_testExecutionEndTime - _testExecutionStartTime).Value : TimeSpan.Zero; + + bool colorizeFailed = failed > 0; + bool colorizePassed = passed > 0 && _buildErrorsCount == 0 && failed == 0; + bool colorizeSkipped = skipped > 0 && skipped == total && _buildErrorsCount == 0 && failed == 0; + + string totalText = $"{SingleIndentation}total: {total}"; + string failedText = $"{SingleIndentation}failed: {failed}"; + string passedText = $"{SingleIndentation}succeeded: {passed}"; + string skippedText = $"{SingleIndentation}skipped: {skipped}"; + string durationText = $"{SingleIndentation}duration: "; + + terminal.ResetColor(); + terminal.AppendLine(totalText); + if (colorizeFailed) + { + terminal.SetColor(TerminalColor.Red); + } + + terminal.AppendLine(failedText); + + if (colorizeFailed) + { + terminal.ResetColor(); + } + + if (colorizePassed) + { + terminal.SetColor(TerminalColor.Green); + } + + terminal.AppendLine(passedText); + + if (colorizePassed) + { + terminal.ResetColor(); + } + + if (colorizeSkipped) + { + terminal.SetColor(TerminalColor.Yellow); + } + + terminal.AppendLine(skippedText); + + if (colorizeSkipped) + { + terminal.ResetColor(); + } + + terminal.Append(durationText); + AppendLongDuration(terminal, runDuration, wrapInParentheses: false, colorize: false); + terminal.AppendLine(); + } + + /// + /// Print a build result summary to the output. + /// + private static void AppendAssemblyResult(ITerminal terminal, bool succeeded, int countErrors, int countWarnings) + { + if (!succeeded) + { + terminal.SetColor(TerminalColor.Red); + // If the build failed, we print one of three red strings. + string text = (countErrors > 0, countWarnings > 0) switch + { + (true, true) => string.Format(CultureInfo.CurrentCulture, LocalizableStrings.FailedWithErrorsAndWarnings, countErrors, countWarnings), + (true, _) => string.Format(CultureInfo.CurrentCulture, LocalizableStrings.FailedWithErrors, countErrors), + (false, true) => string.Format(CultureInfo.CurrentCulture, LocalizableStrings.FailedWithWarnings, countWarnings), + _ => LocalizableStrings.FailedLowercase, + }; + terminal.Append(text); + terminal.ResetColor(); + } + else if (countWarnings > 0) + { + terminal.SetColor(TerminalColor.Yellow); + terminal.Append($"succeeded with {countWarnings} warning(s)"); + terminal.ResetColor(); + } + else + { + terminal.SetColor(TerminalColor.Green); + terminal.Append(LocalizableStrings.PassedLowercase); + terminal.ResetColor(); + } + } + + internal void TestCompleted( + string assembly, + string? targetFramework, + string? architecture, + string? executionId, + string testNodeUid, + string displayName, + TestOutcome outcome, + TimeSpan duration, + string? errorMessage, + Exception? exception, + string? expected, + string? actual, + string? standardOutput, + string? errorOutput) + { + FlatException[] flatExceptions = ExceptionFlattener.Flatten(errorMessage, exception); + TestCompleted( + assembly, + targetFramework, + architecture, + executionId, + testNodeUid, + displayName, + outcome, + duration, + flatExceptions, + expected, + actual, + standardOutput, + errorOutput); + } + + internal void TestCompleted( + string assembly, + string? targetFramework, + string? architecture, + string? executionId, + string testNodeUid, + string displayName, + TestOutcome outcome, + TimeSpan duration, + FlatException[] exceptions, + string? expected, + string? actual, + string? standardOutput, + string? errorOutput) + { + TestProgressState asm = _assemblies[$"{assembly}|{targetFramework}|{architecture}|{executionId}"]; + + if (_options.ShowActiveTests) + { + asm.TestNodeResultsState?.RemoveRunningTestNode(testNodeUid); + } + + switch (outcome) + { + case TestOutcome.Error: + case TestOutcome.Timeout: + case TestOutcome.Canceled: + case TestOutcome.Fail: + asm.FailedTests++; + asm.TotalTests++; + break; + case TestOutcome.Passed: + asm.PassedTests++; + asm.TotalTests++; + break; + case TestOutcome.Skipped: + asm.SkippedTests++; + asm.TotalTests++; + break; + } + + _terminalWithProgress.UpdateWorker(asm.SlotIndex); + if (outcome != TestOutcome.Passed || GetShowPassedTests()) + { + _terminalWithProgress.WriteToTerminal(terminal => RenderTestCompleted( + terminal, + assembly, + targetFramework, + architecture, + displayName, + outcome, + duration, + exceptions, + expected, + actual, + standardOutput, + errorOutput)); + } + } + + private bool GetShowPassedTests() + { + _shouldShowPassedTests ??= _options.ShowPassedTests(); + return _shouldShowPassedTests.Value; + } + + internal /* for testing */ void RenderTestCompleted( + ITerminal terminal, + string assembly, + string? targetFramework, + string? architecture, + string displayName, + TestOutcome outcome, + TimeSpan duration, + FlatException[] flatExceptions, + string? expected, + string? actual, + string? standardOutput, + string? errorOutput) + { + if (outcome == TestOutcome.Passed && !GetShowPassedTests()) + { + return; + } + + TerminalColor color = outcome switch + { + TestOutcome.Error or TestOutcome.Fail or TestOutcome.Canceled or TestOutcome.Timeout => TerminalColor.Red, + TestOutcome.Skipped => TerminalColor.Yellow, + TestOutcome.Passed => TerminalColor.Green, + _ => throw new NotSupportedException(), + }; + string outcomeText = outcome switch + { + TestOutcome.Fail or TestOutcome.Error => LocalizableStrings.FailedLowercase, + TestOutcome.Skipped => LocalizableStrings.SkippedLowercase, + TestOutcome.Canceled or TestOutcome.Timeout => $"{LocalizableStrings.FailedLowercase} ({LocalizableStrings.CancelledLowercase})", + TestOutcome.Passed => LocalizableStrings.PassedLowercase, + _ => throw new NotSupportedException(), + }; + + terminal.SetColor(color); + terminal.Append(outcomeText); + terminal.ResetColor(); + terminal.Append(' '); + terminal.Append(displayName); + terminal.SetColor(TerminalColor.DarkGray); + terminal.Append(' '); + AppendLongDuration(terminal, duration); + if (_options.ShowAssembly) + { + terminal.AppendLine(); + terminal.Append(SingleIndentation); + terminal.Append(LocalizableStrings.FromFile); + terminal.Append(' '); + AppendAssemblyLinkTargetFrameworkAndArchitecture(terminal, assembly, targetFramework, architecture); + } + + terminal.AppendLine(); + + FormatErrorMessage(terminal, flatExceptions, outcome, 0); + FormatExpectedAndActual(terminal, expected, actual); + FormatStackTrace(terminal, flatExceptions, 0); + FormatInnerExceptions(terminal, flatExceptions); + FormatStandardAndErrorOutput(terminal, standardOutput, errorOutput); + } + + private static void FormatInnerExceptions(ITerminal terminal, FlatException[] exceptions) + { + if (exceptions is null || exceptions.Length == 0) + { + return; + } + + for (int i = 1; i < exceptions.Length; i++) + { + terminal.SetColor(TerminalColor.Red); + terminal.Append(SingleIndentation); + terminal.Append("--->"); + FormatErrorMessage(terminal, exceptions, TestOutcome.Error, i); + FormatStackTrace(terminal, exceptions, i); + } + } + + private static void FormatErrorMessage(ITerminal terminal, FlatException[] exceptions, TestOutcome outcome, int index) + { + string? firstErrorMessage = GetStringFromIndexOrDefault(exceptions, e => e.ErrorMessage, index); + string? firstErrorType = GetStringFromIndexOrDefault(exceptions, e => e.ErrorType, index); + string? firstStackTrace = GetStringFromIndexOrDefault(exceptions, e => e.StackTrace, index); + if (String.IsNullOrWhiteSpace(firstErrorMessage) && String.IsNullOrWhiteSpace(firstErrorType) && String.IsNullOrWhiteSpace(firstStackTrace)) + { + return; + } + + terminal.SetColor(TerminalColor.Red); + + if (firstStackTrace is null) + { + AppendIndentedLine(terminal, firstErrorMessage, SingleIndentation); + } + else if (outcome == TestOutcome.Fail) + { + // For failed tests, we don't prefix the message with the exception type because it is most likely an assertion specific exception like AssertionFailedException, and we prefer to show that without the exception type to avoid additional noise. + AppendIndentedLine(terminal, firstErrorMessage, SingleIndentation); + } + else + { + AppendIndentedLine(terminal, $"{firstErrorType}: {firstErrorMessage}", SingleIndentation); + } + + terminal.ResetColor(); + } + + private static string? GetStringFromIndexOrDefault(FlatException[] exceptions, Func property, int index) => + exceptions != null && exceptions.Length >= index + 1 ? property(exceptions[index]) : null; + + private static void FormatExpectedAndActual(ITerminal terminal, string? expected, string? actual) + { + if (String.IsNullOrWhiteSpace(expected) && String.IsNullOrWhiteSpace(actual)) + { + return; + } + + terminal.SetColor(TerminalColor.Red); + terminal.Append(SingleIndentation); + terminal.AppendLine(LocalizableStrings.Expected); + AppendIndentedLine(terminal, expected, DoubleIndentation); + terminal.Append(SingleIndentation); + terminal.AppendLine(LocalizableStrings.Actual); + AppendIndentedLine(terminal, actual, DoubleIndentation); + terminal.ResetColor(); + } + + private static void FormatStackTrace(ITerminal terminal, FlatException[] exceptions, int index) + { + string? stackTrace = GetStringFromIndexOrDefault(exceptions, e => e.StackTrace, index); + if (String.IsNullOrWhiteSpace(stackTrace)) + { + return; + } + + terminal.SetColor(TerminalColor.DarkGray); + + string[] lines = stackTrace.Split(NewLineStrings, StringSplitOptions.None); + foreach (string line in lines) + { + AppendStackFrame(terminal, line); + } + + terminal.ResetColor(); + } + + private static void FormatStandardAndErrorOutput(ITerminal terminal, string? standardOutput, string? standardError) + { + if (String.IsNullOrWhiteSpace(standardOutput) && String.IsNullOrWhiteSpace(standardError)) + { + return; + } + + terminal.SetColor(TerminalColor.DarkGray); + terminal.Append(SingleIndentation); + terminal.AppendLine(LocalizableStrings.StandardOutput); + string? standardOutputWithoutSpecialChars = NormalizeSpecialCharacters(standardOutput); + AppendIndentedLine(terminal, standardOutputWithoutSpecialChars, DoubleIndentation); + terminal.Append(SingleIndentation); + terminal.AppendLine(LocalizableStrings.StandardError); + string? standardErrorWithoutSpecialChars = NormalizeSpecialCharacters(standardError); + AppendIndentedLine(terminal, standardErrorWithoutSpecialChars, DoubleIndentation); + terminal.ResetColor(); + } + + private static void AppendAssemblyLinkTargetFrameworkAndArchitecture(ITerminal terminal, string assembly, string? targetFramework, string? architecture) + { + terminal.AppendLink(assembly, lineNumber: null); + if (targetFramework != null || architecture != null) + { + terminal.Append(" ("); + if (targetFramework != null) + { + terminal.Append(targetFramework); + terminal.Append('|'); + } + + if (architecture != null) + { + terminal.Append(architecture); + } + + terminal.Append(')'); + } + } + + internal /* for testing */ static void AppendStackFrame(ITerminal terminal, string stackTraceLine) + { + terminal.Append(DoubleIndentation); + Match match = GetFrameRegex().Match(stackTraceLine); + if (match.Success) + { + bool weHaveFilePathAndCodeLine = !String.IsNullOrWhiteSpace(match.Groups["code"].Value); + terminal.Append(LocalizableStrings.StackFrameAt); + terminal.Append(' '); + + if (weHaveFilePathAndCodeLine) + { + terminal.Append(match.Groups["code"].Value); + } + else + { + terminal.Append(match.Groups["code1"].Value); + } + + if (weHaveFilePathAndCodeLine) + { + terminal.Append(' '); + terminal.Append(LocalizableStrings.StackFrameIn); + terminal.Append(' '); + if (!String.IsNullOrWhiteSpace(match.Groups["file"].Value)) + { + int line = int.TryParse(match.Groups["line"].Value, out int value) ? value : 0; + terminal.AppendLink(match.Groups["file"].Value, line); + + // AppendLink finishes by resetting color + terminal.SetColor(TerminalColor.DarkGray); + } + } + + terminal.AppendLine(); + } + else + { + terminal.AppendLine(stackTraceLine); + } + } + + private static void AppendIndentedLine(ITerminal terminal, string? message, string indent) + { + if (String.IsNullOrWhiteSpace(message)) + { + return; + } + + if (!message.Contains('\n')) + { + terminal.Append(indent); + terminal.AppendLine(message); + return; + } + + string[] lines = message.Split(NewLineStrings, StringSplitOptions.None); + foreach (string line in lines) + { + // Here we could check if the messages are longer than then line, and reflow them so a long line is split into multiple + // and prepended by the respective indentation. + // But this does not play nicely with ANSI escape codes. And if you + // run in narrow terminal and then widen it the text does not reflow correctly. And you also have harder time copying + // values when the assertion message is longer. + terminal.Append(indent); + terminal.Append(line); + terminal.AppendLine(); + } + } + + internal void AssemblyRunCompleted(string assembly, string? targetFramework, string? architecture, string? executionId, + // These parameters are useful only for "remote" runs in dotnet test, where we are reporting on multiple processes. + // In single process run, like with testing platform .exe we report these via messages, and run exit. + int? exitCode, string? outputData, string? errorData) + { + TestProgressState assemblyRun = GetOrAddAssemblyRun(assembly, targetFramework, architecture, executionId); + assemblyRun.Stopwatch.Stop(); + + _terminalWithProgress.RemoveWorker(assemblyRun.SlotIndex); + + if (!_isDiscovery && _options.ShowAssembly && _options.ShowAssemblyStartAndComplete) + { + _terminalWithProgress.WriteToTerminal(terminal => AppendAssemblySummary(assemblyRun, terminal)); + } + + if (exitCode is null or 0) + { + // Report nothing, we don't want to report on success, because then we will also report on test-discovery etc. + return; + } + + _terminalWithProgress.WriteToTerminal(terminal => + { + AppendExecutableSummary(terminal, exitCode, outputData, errorData); + terminal.AppendLine(); + }); + } + + private static void AppendExecutableSummary(ITerminal terminal, int? exitCode, string? outputData, string? errorData) + { + terminal.AppendLine(); + terminal.Append(LocalizableStrings.ExitCode); + terminal.Append(": "); + terminal.AppendLine(exitCode?.ToString(CultureInfo.CurrentCulture) ?? ""); + terminal.Append(LocalizableStrings.StandardOutput); + terminal.AppendLine(":"); + terminal.AppendLine(String.IsNullOrWhiteSpace(outputData) ? string.Empty : outputData); + terminal.Append(LocalizableStrings.StandardError); + terminal.AppendLine(":"); + terminal.AppendLine(String.IsNullOrWhiteSpace(errorData) ? string.Empty : errorData); + } + + private static string? NormalizeSpecialCharacters(string? text) + => text?.Replace('\0', '\x2400') + // escape char + .Replace('\x001b', '\x241b'); + + private static void AppendAssemblySummary(TestProgressState assemblyRun, ITerminal terminal) + { + int failedTests = assemblyRun.FailedTests; + int warnings = 0; + + AppendAssemblyLinkTargetFrameworkAndArchitecture(terminal, assemblyRun.Assembly, assemblyRun.TargetFramework, assemblyRun.Architecture); + terminal.Append(' '); + AppendAssemblyResult(terminal, assemblyRun.FailedTests == 0, failedTests, warnings); + terminal.Append(' '); + AppendLongDuration(terminal, assemblyRun.Stopwatch.Elapsed); + } + + /// + /// Appends a long duration in human readable format such as 1h 23m 500ms. + /// + private static void AppendLongDuration(ITerminal terminal, TimeSpan duration, bool wrapInParentheses = true, bool colorize = true) + { + if (colorize) + { + terminal.SetColor(TerminalColor.DarkGray); + } + + HumanReadableDurationFormatter.Append(terminal, duration, wrapInParentheses); + + if (colorize) + { + terminal.ResetColor(); + } + } + + public void Dispose() => _terminalWithProgress.Dispose(); + + public void ArtifactAdded(bool outOfProcess, string? assembly, string? targetFramework, string? architecture, string? executionId, string? testName, string path) + => _artifacts.Add(new TestRunArtifact(outOfProcess, assembly, targetFramework, architecture, executionId, testName, path)); + + /// + /// Let the user know that cancellation was triggered. + /// + public void StartCancelling() + { + _wasCancelled = true; + _terminalWithProgress.WriteToTerminal(terminal => + { + terminal.AppendLine(); + terminal.AppendLine(LocalizableStrings.CancellingTestSession); + terminal.AppendLine(); + }); + } + + internal void WriteErrorMessage(string assembly, string? targetFramework, string? architecture, string? executionId, string text, int? padding) + { + TestProgressState asm = GetOrAddAssemblyRun(assembly, targetFramework, architecture, executionId); + asm.AddError(text); + + _terminalWithProgress.WriteToTerminal(terminal => + { + terminal.SetColor(TerminalColor.Red); + if (padding == null) + { + terminal.AppendLine(text); + } + else + { + AppendIndentedLine(terminal, text, new string(' ', padding.Value)); + } + + terminal.ResetColor(); + }); + } + + internal void WriteWarningMessage(string assembly, string? targetFramework, string? architecture, string? executionId, string text, int? padding) + { + TestProgressState asm = GetOrAddAssemblyRun(assembly, targetFramework, architecture, executionId); + asm.AddWarning(text); + _terminalWithProgress.WriteToTerminal(terminal => + { + terminal.SetColor(TerminalColor.Yellow); + if (padding == null) + { + terminal.AppendLine(text); + } + else + { + AppendIndentedLine(terminal, text, new string(' ', padding.Value)); + } + + terminal.ResetColor(); + }); + } + + internal void WriteErrorMessage(string assembly, string? targetFramework, string? architecture, string? executionId, Exception exception) + => WriteErrorMessage(assembly, targetFramework, architecture, executionId, exception.ToString(), padding: null); + + public void WriteMessage(string text, SystemConsoleColor? color = null, int? padding = null) + { + if (color != null) + { + _terminalWithProgress.WriteToTerminal(terminal => + { + terminal.SetColor(ToTerminalColor(color.ConsoleColor)); + if (padding == null) + { + terminal.AppendLine(text); + } + else + { + AppendIndentedLine(terminal, text, new string(' ', padding.Value)); + } + + terminal.ResetColor(); + }); + } + else + { + _terminalWithProgress.WriteToTerminal(terminal => + { + if (padding == null) + { + terminal.AppendLine(text); + } + else + { + AppendIndentedLine(terminal, text, new string(' ', padding.Value)); + } + }); + } + } + + internal void TestDiscovered( + string assembly, + string? targetFramework, + string? architecture, + string? executionId, + string? displayName, + string? uid) + { + TestProgressState asm = _assemblies[$"{assembly}|{targetFramework}|{architecture}|{executionId}"]; + + // TODO: add mode for discovered tests to the progress bar - jajares + asm.PassedTests++; + asm.TotalTests++; + asm.DiscoveredTests.Add(new(displayName, uid)); + _terminalWithProgress.UpdateWorker(asm.SlotIndex); + } + + public void AppendTestDiscoverySummary(ITerminal terminal) + { + terminal.AppendLine(); + + var assemblies = _assemblies.Select(asm => asm.Value).OrderBy(a => a.Assembly).Where(a => a is not null).ToList(); + + int totalTests = _assemblies.Values.Sum(a => a.TotalTests); + bool runFailed = _wasCancelled; + + foreach (TestProgressState assembly in assemblies) + { + terminal.Append(string.Format(CultureInfo.CurrentCulture, LocalizableStrings.DiscoveredTestsInAssembly, assembly.DiscoveredTests.Count)); + terminal.Append(" - "); + AppendAssemblyLinkTargetFrameworkAndArchitecture(terminal, assembly.Assembly, assembly.TargetFramework, assembly.Architecture); + terminal.AppendLine(); + foreach ((string? displayName, string? uid) in assembly.DiscoveredTests) + { + if (displayName is not null) + { + terminal.Append(SingleIndentation); + terminal.AppendLine(displayName); + } + } + + terminal.AppendLine(); + } + + terminal.SetColor(runFailed ? TerminalColor.Red : TerminalColor.Green); + if (assemblies.Count <= 1) + { + terminal.AppendLine(string.Format(CultureInfo.CurrentCulture, LocalizableStrings.TestDiscoverySummarySingular, totalTests)); + } + else + { + terminal.AppendLine(string.Format(CultureInfo.CurrentCulture, LocalizableStrings.TestDiscoverySummary, totalTests, assemblies.Count)); + } + + terminal.ResetColor(); + terminal.AppendLine(); + + if (_wasCancelled) + { + terminal.Append(LocalizableStrings.Aborted); + terminal.AppendLine(); + } + } + + public void AssemblyDiscoveryCompleted(int testCount) => + _terminalWithProgress.WriteToTerminal(terminal => terminal.Append($"Found {testCount} tests")); + + private static TerminalColor ToTerminalColor(ConsoleColor consoleColor) + => consoleColor switch + { + ConsoleColor.Black => TerminalColor.Black, + ConsoleColor.DarkBlue => TerminalColor.DarkBlue, + ConsoleColor.DarkGreen => TerminalColor.DarkGreen, + ConsoleColor.DarkCyan => TerminalColor.DarkCyan, + ConsoleColor.DarkRed => TerminalColor.DarkRed, + ConsoleColor.DarkMagenta => TerminalColor.DarkMagenta, + ConsoleColor.DarkYellow => TerminalColor.DarkYellow, + ConsoleColor.DarkGray => TerminalColor.DarkGray, + ConsoleColor.Gray => TerminalColor.Gray, + ConsoleColor.Blue => TerminalColor.Blue, + ConsoleColor.Green => TerminalColor.Green, + ConsoleColor.Cyan => TerminalColor.Cyan, + ConsoleColor.Red => TerminalColor.Red, + ConsoleColor.Magenta => TerminalColor.Magenta, + ConsoleColor.Yellow => TerminalColor.Yellow, + ConsoleColor.White => TerminalColor.White, + _ => TerminalColor.Default, + }; + + public void TestInProgress( + string assembly, + string? targetFramework, + string? architecture, + string testNodeUid, + string displayName, + string? executionId) + { + TestProgressState asm = _assemblies[$"{assembly}|{targetFramework}|{architecture}|{executionId}"]; + + if (_options.ShowActiveTests) + { + asm.TestNodeResultsState ??= new(Interlocked.Increment(ref _counter)); + asm.TestNodeResultsState.AddRunningTestNode( + Interlocked.Increment(ref _counter), testNodeUid, displayName, CreateStopwatch()); + } + + _terminalWithProgress.UpdateWorker(asm.SlotIndex); + } +} diff --git a/src/Cli/dotnet/commands/dotnet-test/Terminal/TerminalTestReporterOptions.cs b/src/Cli/dotnet/commands/dotnet-test/Terminal/TerminalTestReporterOptions.cs new file mode 100644 index 000000000000..478453758d30 --- /dev/null +++ b/src/Cli/dotnet/commands/dotnet-test/Terminal/TerminalTestReporterOptions.cs @@ -0,0 +1,54 @@ +// 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.OutputDevice.Terminal; + +internal sealed class TerminalTestReporterOptions +{ + /// + /// Gets path to which all other paths in output should be relative. + /// + public string? BaseDirectory { get; init; } + + /// + /// Gets a value indicating whether we should show passed tests. + /// + public Func ShowPassedTests { get; init; } = () => true; + + /// + /// Gets a value indicating whether we should show information about which assembly is the source of the data on screen. Turn this off when running directly from an exe to reduce noise, because the path will always be the same. + /// + public bool ShowAssembly { get; init; } + + /// + /// Gets a value indicating whether we should show information about which assembly started or completed. Turn this off when running directly from an exe to reduce noise, because the path will always be the same. + /// + public bool ShowAssemblyStartAndComplete { get; init; } + + /// + /// Gets minimum amount of tests to run. + /// + public int MinimumExpectedTests { get; init; } + + /// + /// Gets a value indicating whether we should write the progress periodically to screen. When ANSI is allowed we update the progress as often as we can. When ANSI is not allowed we update it every 3 seconds. + /// This is a callback to nullable bool, because we don't know if we are running as test host controller until after we setup the console. So we should be polling for the value, until we get non-null boolean + /// and then cache that value. + /// + public Func ShowProgress { get; init; } = () => true; + + /// + /// Gets a value indicating whether the active tests should be visible when the progress is shown. + /// + public bool ShowActiveTests { get; init; } + + /// + /// Gets a value indicating whether we should use ANSI escape codes or disable them. When true the capabilities of the console are autodetected. + /// + public bool UseAnsi { get; init; } + + /// + /// Gets a value indicating whether we should force ANSI escape codes. When true the ANSI is used without auto-detecting capabilities of the console. This is needed only for testing. + /// + internal /* for testing */ bool? ForceAnsi { get; init; } +} diff --git a/src/Cli/dotnet/commands/dotnet-test/Terminal/TestDetailState.cs b/src/Cli/dotnet/commands/dotnet-test/Terminal/TestDetailState.cs new file mode 100644 index 000000000000..ad00e3ee42af --- /dev/null +++ b/src/Cli/dotnet/commands/dotnet-test/Terminal/TestDetailState.cs @@ -0,0 +1,37 @@ +// 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.Helpers; + +namespace Microsoft.Testing.Platform.OutputDevice.Terminal; + +internal sealed class TestDetailState +{ + private string _text; + + public TestDetailState(long id, IStopwatch? stopwatch, string text) + { + Id = id; + Stopwatch = stopwatch; + _text = text; + } + + public long Id { get; } + + public long Version { get; set; } + + public IStopwatch? Stopwatch { get; } + + public string Text + { + get => _text; + set + { + if (!_text.Equals(value, StringComparison.Ordinal)) + { + Version++; + _text = value; + } + } + } +} diff --git a/src/Cli/dotnet/commands/dotnet-test/Terminal/TestNodeResultsState.cs b/src/Cli/dotnet/commands/dotnet-test/Terminal/TestNodeResultsState.cs new file mode 100644 index 000000000000..c7a6609e5410 --- /dev/null +++ b/src/Cli/dotnet/commands/dotnet-test/Terminal/TestNodeResultsState.cs @@ -0,0 +1,63 @@ +// 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.Collections.Concurrent; +using System.Globalization; +using Microsoft.Testing.Platform.Helpers; +using LocalizableStrings = Microsoft.DotNet.Tools.Test.LocalizableStrings; + +namespace Microsoft.Testing.Platform.OutputDevice.Terminal; + +internal sealed class TestNodeResultsState +{ + public TestNodeResultsState(long id) + { + Id = id; + _summaryDetail = new(id, stopwatch: null, text: string.Empty); + } + + public long Id { get; } + + private readonly TestDetailState _summaryDetail; + private readonly ConcurrentDictionary _testNodeProgressStates = new(); + + public int Count => _testNodeProgressStates.Count; + + public void AddRunningTestNode(int id, string uid, string name, IStopwatch stopwatch) => _testNodeProgressStates[uid] = new TestDetailState(id, stopwatch, name); + + public void RemoveRunningTestNode(string uid) => _testNodeProgressStates.TryRemove(uid, out _); + + public IEnumerable GetRunningTasks(int maxCount) + { + var sortedDetails = _testNodeProgressStates + .Select(d => d.Value) + .OrderByDescending(d => d.Stopwatch?.Elapsed ?? TimeSpan.Zero) + .ToList(); + + bool tooManyItems = sortedDetails.Count > maxCount; + + if (tooManyItems) + { + // Note: If there's too many items to display, the summary will take up one line. + // As such, we can only take maxCount - 1 items. + int itemsToTake = maxCount - 1; + _summaryDetail.Text = + itemsToTake == 0 + // Note: If itemsToTake is 0, then we only show two lines, the project summary and the number of running tests. + ? string.Format(CultureInfo.CurrentCulture, LocalizableStrings.ActiveTestsRunning_FullTestsCount, sortedDetails.Count) + // If itemsToTake is larger, then we show the project summary, active tests, and the number of active tests that are not shown. + : $"... {string.Format(CultureInfo.CurrentCulture, LocalizableStrings.ActiveTestsRunning_MoreTestsCount, sortedDetails.Count - itemsToTake)}"; + sortedDetails = sortedDetails.Take(itemsToTake).ToList(); + } + + foreach (TestDetailState? detail in sortedDetails) + { + yield return detail; + } + + if (tooManyItems) + { + yield return _summaryDetail; + } + } +} diff --git a/src/Cli/dotnet/commands/dotnet-test/Terminal/TestOutcome.cs b/src/Cli/dotnet/commands/dotnet-test/Terminal/TestOutcome.cs new file mode 100644 index 000000000000..b60208af292e --- /dev/null +++ b/src/Cli/dotnet/commands/dotnet-test/Terminal/TestOutcome.cs @@ -0,0 +1,40 @@ +// 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.OutputDevice.Terminal; + +/// +/// Outcome of a test. +/// +internal enum TestOutcome +{ + /// + /// Error. + /// + Error, + + /// + /// Fail. + /// + Fail, + + /// + /// Passed. + /// + Passed, + + /// + /// Skipped. + /// + Skipped, + + /// + /// Timeout. + /// + Timeout, + + /// + /// Canceled. + /// + Canceled, +} diff --git a/src/Cli/dotnet/commands/dotnet-test/Terminal/TestProgressState.cs b/src/Cli/dotnet/commands/dotnet-test/Terminal/TestProgressState.cs new file mode 100644 index 000000000000..7824e12bc0fa --- /dev/null +++ b/src/Cli/dotnet/commands/dotnet-test/Terminal/TestProgressState.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.Helpers; + +namespace Microsoft.Testing.Platform.OutputDevice.Terminal; + +internal sealed class TestProgressState +{ + public TestProgressState(long id, string assembly, string? targetFramework, string? architecture, IStopwatch stopwatch) + { + Id = id; + Assembly = assembly; + TargetFramework = targetFramework; + Architecture = architecture; + Stopwatch = stopwatch; + AssemblyName = Path.GetFileName(assembly)!; + } + + public string Assembly { get; } + + public string AssemblyName { get; } + + public string? TargetFramework { get; } + + public string? Architecture { get; } + + public IStopwatch Stopwatch { get; } + + public List Attachments { get; } = new(); + + public List Messages { get; } = new(); + + public int FailedTests { get; internal set; } + + public int PassedTests { get; internal set; } + + public int SkippedTests { get; internal set; } + + public int TotalTests { get; internal set; } + + public TestNodeResultsState? TestNodeResultsState { get; internal set; } + + public int SlotIndex { get; internal set; } + + public long Id { get; internal set; } + + public long Version { get; internal set; } + + public List<(string? DisplayName, string? UID)> DiscoveredTests { get; internal set; } = new(); + + internal void AddError(string text) + => Messages.Add(new ErrorMessage(text)); + + internal void AddWarning(string text) + => Messages.Add(new WarningMessage(text)); +} diff --git a/src/Cli/dotnet/commands/dotnet-test/Terminal/TestProgressStateAwareTerminal.cs b/src/Cli/dotnet/commands/dotnet-test/Terminal/TestProgressStateAwareTerminal.cs new file mode 100644 index 000000000000..d369d99059ed --- /dev/null +++ b/src/Cli/dotnet/commands/dotnet-test/Terminal/TestProgressStateAwareTerminal.cs @@ -0,0 +1,205 @@ +// 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.OutputDevice.Terminal; + +/// +/// Terminal that updates the progress in place when progress reporting is enabled. +/// +internal sealed partial class TestProgressStateAwareTerminal : IDisposable +{ + /// + /// A cancellation token to signal the rendering thread that it should exit. + /// + private readonly CancellationTokenSource _cts = new(); + + /// + /// Protects access to state shared between the logger callbacks and the rendering thread. + /// + private readonly Lock _lock = new(); + + private readonly ITerminal _terminal; + private readonly Func _showProgress; + private readonly bool _writeProgressImmediatelyAfterOutput; + private readonly int _updateEvery; + private TestProgressState?[] _progressItems = []; + private bool? _showProgressCached; + + /// + /// The thread that performs periodic refresh of the console output. + /// + private Thread? _refresher; + private long _counter; + + public event EventHandler? OnProgressStartUpdate; + + public event EventHandler? OnProgressStopUpdate; + + /// + /// The thread proc. + /// + private void ThreadProc() + { + try + { + while (!_cts.Token.WaitHandle.WaitOne(_updateEvery)) + { + lock (_lock) + { + OnProgressStartUpdate?.Invoke(this, EventArgs.Empty); + _terminal.StartUpdate(); + try + { + _terminal.RenderProgress(_progressItems); + } + finally + { + _terminal.StopUpdate(); + OnProgressStopUpdate?.Invoke(this, EventArgs.Empty); + } + } + } + } + catch (ObjectDisposedException) + { + // When we dispose _cts too early this will throw. + } + + _terminal.EraseProgress(); + } + + public TestProgressStateAwareTerminal(ITerminal terminal, Func showProgress, bool writeProgressImmediatelyAfterOutput, int updateEvery) + { + _terminal = terminal; + _showProgress = showProgress; + _writeProgressImmediatelyAfterOutput = writeProgressImmediatelyAfterOutput; + _updateEvery = updateEvery; + } + + public int AddWorker(TestProgressState testWorker) + { + if (GetShowProgress()) + { + for (int i = 0; i < _progressItems.Length; i++) + { + if (_progressItems[i] == null) + { + _progressItems[i] = testWorker; + return i; + } + } + + throw new InvalidOperationException("No empty slot found"); + } + + return 0; + } + + public void StartShowingProgress(int workerCount) + { + if (GetShowProgress()) + { + _progressItems = new TestProgressState[workerCount]; + _terminal.StartBusyIndicator(); + // If we crash unexpectedly without completing this thread we don't want it to keep the process running. + _refresher = new Thread(ThreadProc) { IsBackground = true }; + _refresher.Start(); + } + } + + internal void StopShowingProgress() + { + if (GetShowProgress()) + { + _cts.Cancel(); + _refresher?.Join(); + + _terminal.EraseProgress(); + _terminal.StopBusyIndicator(); + } + } + + public void Dispose() => + ((IDisposable)_cts).Dispose(); + + internal void WriteToTerminal(Action write) + { + if (GetShowProgress()) + { + lock (_lock) + { + try + { + _terminal.StartUpdate(); + _terminal.EraseProgress(); + write(_terminal); + if (_writeProgressImmediatelyAfterOutput) + { + _terminal.RenderProgress(_progressItems); + } + } + finally + { + _terminal.StopUpdate(); + } + } + } + else + { + lock (_lock) + { + try + { + _terminal.StartUpdate(); + write(_terminal); + } + finally + { + _terminal.StopUpdate(); + } + } + } + } + + internal void RemoveWorker(int slotIndex) + { + if (GetShowProgress()) + { + _progressItems[slotIndex] = null; + } + } + + internal void UpdateWorker(int slotIndex) + { + if (GetShowProgress()) + { + // We increase the counter to say that this version of data is newer than what we had before and + // it should be completely re-rendered. Another approach would be to use timestamps, or to replace the + // instance and compare that, but that means more objects floating around. + _counter++; + + TestProgressState? progress = _progressItems[slotIndex]; + if (progress != null) + { + progress.Version = _counter; + } + } + } + + private bool GetShowProgress() + { + if (_showProgressCached != null) + { + return _showProgressCached.Value; + } + + // Get the value from the func until we get the first non-null value. + bool? showProgress = _showProgress(); + if (showProgress != null) + { + _showProgressCached = showProgress; + } + + return showProgress == true; + } +} diff --git a/src/Cli/dotnet/commands/dotnet-test/Terminal/TestRunArtifact.cs b/src/Cli/dotnet/commands/dotnet-test/Terminal/TestRunArtifact.cs new file mode 100644 index 000000000000..e8eba56edc34 --- /dev/null +++ b/src/Cli/dotnet/commands/dotnet-test/Terminal/TestRunArtifact.cs @@ -0,0 +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. + +namespace Microsoft.Testing.Platform.OutputDevice.Terminal; + +/// +/// An artifact / attachment that was reported during run. +/// +internal sealed record TestRunArtifact(bool OutOfProcess, string? Assembly, string? TargetFramework, string? Architecture, string? ExecutionId, string? TestName, string Path); diff --git a/src/Cli/dotnet/commands/dotnet-test/Terminal/WarningMessage.cs b/src/Cli/dotnet/commands/dotnet-test/Terminal/WarningMessage.cs new file mode 100644 index 000000000000..c938898aa76a --- /dev/null +++ b/src/Cli/dotnet/commands/dotnet-test/Terminal/WarningMessage.cs @@ -0,0 +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. + +namespace Microsoft.Testing.Platform.OutputDevice.Terminal; + +/// +/// A warning message that was sent during run. +/// +internal sealed record WarningMessage(string Text) : IProgressMessage; diff --git a/src/Cli/dotnet/commands/dotnet-test/TestApplication.cs b/src/Cli/dotnet/commands/dotnet-test/TestApplication.cs index ef1d4b0c7e5b..6918d2bcc2a7 100644 --- a/src/Cli/dotnet/commands/dotnet-test/TestApplication.cs +++ b/src/Cli/dotnet/commands/dotnet-test/TestApplication.cs @@ -272,6 +272,11 @@ private string BuildArgsWithDotnetRun(bool hasHelp, BuildConfigurationOptions bu builder.Append($" {TestingPlatformOptions.NoBuildOption.Name}"); } + if (buildConfigurationOptions.HasListTests) + { + builder.Append($" {TestingPlatformOptions.ListTestsOption.Name}"); + } + if (!string.IsNullOrEmpty(buildConfigurationOptions.Architecture)) { builder.Append($" {TestingPlatformOptions.ArchitectureOption.Name} {buildConfigurationOptions.Architecture}"); @@ -351,7 +356,7 @@ internal void OnTestResultMessages(TestResultMessages testResultMessage) { ExecutionId = testResultMessage.ExecutionId, SuccessfulTestResults = testResultMessage.SuccessfulTestMessages.Select(message => new SuccessfulTestResult(message.Uid, message.DisplayName, message.State, message.Duration, message.Reason, message.StandardOutput, message.ErrorOutput, message.SessionUid)).ToArray(), - FailedTestResults = testResultMessage.FailedTestMessages.Select(message => new FailedTestResult(message.Uid, message.DisplayName, message.State, message.Duration, message.Reason, message.ErrorMessage, message.ErrorStackTrace, message.StandardOutput, message.ErrorOutput, message.SessionUid)).ToArray() + FailedTestResults = testResultMessage.FailedTestMessages.Select(message => new FailedTestResult(message.Uid, message.DisplayName, message.State, message.Duration, message.Reason, message.Exceptions.Select(e => new FlatException(e.ErrorMessage, e.ErrorType, e.StackTrace)).ToArray(), message.StandardOutput, message.ErrorOutput, message.SessionUid)).ToArray() }); } diff --git a/src/Cli/dotnet/commands/dotnet-test/TestCommandParser.cs b/src/Cli/dotnet/commands/dotnet-test/TestCommandParser.cs index 42cfdb243f95..df414cbfa4e9 100644 --- a/src/Cli/dotnet/commands/dotnet-test/TestCommandParser.cs +++ b/src/Cli/dotnet/commands/dotnet-test/TestCommandParser.cs @@ -200,6 +200,7 @@ private static CliCommand GetTestingPlatformCliCommand() command.Options.Add(TestingPlatformOptions.ArchitectureOption); command.Options.Add(TestingPlatformOptions.ConfigurationOption); command.Options.Add(TestingPlatformOptions.ProjectOption); + command.Options.Add(TestingPlatformOptions.ListTestsOption); command.Options.Add(TestingPlatformOptions.SolutionOption); command.Options.Add(TestingPlatformOptions.DirectoryOption); diff --git a/src/Cli/dotnet/commands/dotnet-test/TestingPlatformCommand.cs b/src/Cli/dotnet/commands/dotnet-test/TestingPlatformCommand.cs index 84be6429fcdd..40f2d5adf6bc 100644 --- a/src/Cli/dotnet/commands/dotnet-test/TestingPlatformCommand.cs +++ b/src/Cli/dotnet/commands/dotnet-test/TestingPlatformCommand.cs @@ -1,10 +1,14 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System; using System.Collections.Concurrent; using System.CommandLine; using Microsoft.DotNet.Tools.Test; using Microsoft.TemplateEngine.Cli.Commands; +using Microsoft.Testing.Platform.Helpers; +using Microsoft.Testing.Platform.OutputDevice; +using Microsoft.Testing.Platform.OutputDevice.Terminal; namespace Microsoft.DotNet.Cli { @@ -14,8 +18,12 @@ internal partial class TestingPlatformCommand : CliCommand, ICustomHelp private MSBuildHandler _msBuildHandler; private TestModulesFilterHandler _testModulesFilterHandler; + private TerminalTestReporter _output; private TestApplicationActionQueue _actionQueue; private List _args; + private ConcurrentDictionary _executions = new(); + private byte _cancelled; + private bool _isDiscovery; public TestingPlatformCommand(string name, string description = null) : base(name, description) { @@ -27,10 +35,80 @@ public int Run(ParseResult parseResult) bool hasFailed = false; try { + Console.CancelKeyPress += (s, e) => + { + _output?.StartCancelling(); + CompleteRun(); + }; + int degreeOfParallelism = GetDegreeOfParallelism(parseResult); BuildConfigurationOptions buildConfigurationOptions = GetBuildConfigurationOptions(parseResult); InitializeActionQueue(parseResult, degreeOfParallelism, buildConfigurationOptions); + bool filterModeEnabled = parseResult.HasOption(TestingPlatformOptions.TestModulesFilterOption); + + if (filterModeEnabled && parseResult.HasOption(TestingPlatformOptions.ArchitectureOption)) + { + VSTestTrace.SafeWriteTrace(() => $"The --arch option is not supported yet."); + } + + if (parseResult.HasOption(TestingPlatformOptions.ListTestsOption)) + { + _isDiscovery = true; + } + + BuildConfigurationOptions builtInOptions = new( + parseResult.HasOption(TestingPlatformOptions.NoRestoreOption), + parseResult.HasOption(TestingPlatformOptions.NoBuildOption), + parseResult.HasOption(TestingPlatformOptions.ListTestsOption), + parseResult.GetValue(TestingPlatformOptions.ConfigurationOption), + parseResult.GetValue(TestingPlatformOptions.ArchitectureOption)); + + var console = new SystemConsole(); + var output = new TerminalTestReporter(console, new TerminalTestReporterOptions() + { + ShowPassedTests = Environment.GetEnvironmentVariable("SHOW_PASSED") == "1" ? () => true : () => false, + ShowProgress = () => Environment.GetEnvironmentVariable("NO_PROGRESS") != "1", + UseAnsi = Environment.GetEnvironmentVariable("NO_ANSI") != "1", + ShowAssembly = true, + ShowAssemblyStartAndComplete = true, + }); + _output = output; + _output.TestExecutionStarted(DateTimeOffset.Now, degreeOfParallelism, _isDiscovery); + + if (ContainsHelpOption(parseResult.GetArguments())) + { + _actionQueue = new(degreeOfParallelism, async (TestApplication testApp) => + { + testApp.HelpRequested += OnHelpRequested; + testApp.ErrorReceived += OnErrorReceived; + testApp.TestProcessExited += OnTestProcessExited; + testApp.Run += OnTestApplicationRun; + testApp.ExecutionIdReceived += OnExecutionIdReceived; + + var result = await testApp.RunAsync(filterModeEnabled, enableHelp: true, builtInOptions); + CompleteRun(); + return result; + }); + } + else + { + _actionQueue = new(degreeOfParallelism, async (TestApplication testApp) => + { + testApp.HandshakeReceived += OnHandshakeReceived; + testApp.DiscoveredTestsReceived += OnDiscoveredTestsReceived; + testApp.TestResultsReceived += OnTestResultsReceived; + testApp.FileArtifactsReceived += OnFileArtifactsReceived; + testApp.SessionEventReceived += OnSessionEventReceived; + testApp.ErrorReceived += OnErrorReceived; + testApp.TestProcessExited += OnTestProcessExited; + testApp.Run += OnTestApplicationRun; + testApp.ExecutionIdReceived += OnExecutionIdReceived; + + return await testApp.RunAsync(filterModeEnabled, enableHelp: false, builtInOptions); + }); + } + _args = [.. parseResult.UnmatchedTokens]; _msBuildHandler = new(_args, _actionQueue, degreeOfParallelism); _testModulesFilterHandler = new(_args, _actionQueue); @@ -39,6 +117,7 @@ public int Run(ParseResult parseResult) { if (!_testModulesFilterHandler.RunWithTestModulesFilter(parseResult)) { + CompleteRun(); return ExitCodes.GenericFailure; } } @@ -47,12 +126,14 @@ public int Run(ParseResult parseResult) var buildPathOptions = GetBuildPathOptions(parseResult); if (!_msBuildHandler.RunMSBuild(buildPathOptions).GetAwaiter().GetResult()) { + CompleteRun(); return ExitCodes.GenericFailure; } if (!_msBuildHandler.EnqueueTestApplications()) { VSTestTrace.SafeWriteTrace(() => LocalizableStrings.CmdUnsupportedVSTestTestApplicationsDescription); + CompleteRun(); return ExitCodes.GenericFailure; } } @@ -65,6 +146,7 @@ public int Run(ParseResult parseResult) CleanUp(); } + CompleteRun(); return hasFailed ? ExitCodes.GenericFailure : ExitCodes.Success; } @@ -78,6 +160,7 @@ private static int GetDegreeOfParallelism(ParseResult parseResult) private static BuildConfigurationOptions GetBuildConfigurationOptions(ParseResult parseResult) => new(parseResult.HasOption(TestingPlatformOptions.NoRestoreOption), parseResult.HasOption(TestingPlatformOptions.NoBuildOption), + parseResult.HasOption(TestingPlatformOptions.ListTestsOption), parseResult.GetValue(TestingPlatformOptions.ConfigurationOption), parseResult.GetValue(TestingPlatformOptions.ArchitectureOption)); @@ -122,6 +205,14 @@ private void InitializeActionQueue(ParseResult parseResult, int degreeOfParallel private static bool ContainsHelpOption(IEnumerable args) => args.Contains(CliConstants.HelpOptionKey) || args.Contains(CliConstants.HelpOptionKey.Substring(0, 2)); + private void CompleteRun() + { + if (Interlocked.CompareExchange(ref _cancelled, 1, 0) == 0) + { + _output?.TestExecutionCompleted(DateTimeOffset.Now); + } + } + private void CleanUp() { _msBuildHandler.Dispose(); @@ -133,6 +224,14 @@ private void CleanUp() private void OnHandshakeReceived(object sender, HandshakeArgs args) { + var testApplication = (TestApplication)sender; + var executionId = args.Handshake.Properties[HandshakeMessagePropertyNames.ExecutionId]; + var arch = args.Handshake.Properties[HandshakeMessagePropertyNames.Architecture]?.ToLower(); + var tfm = TargetFrameworkParser.GetShortTargetFramework(args.Handshake.Properties[HandshakeMessagePropertyNames.Framework]); + (string ModulePath, string TargetFramework, string Architecture, string ExecutionId) appInfo = new(testApplication.Module.DllOrExePath, tfm, arch, executionId); + _executions[testApplication] = appInfo; + _output.AssemblyRunStarted(appInfo.ModulePath, appInfo.TargetFramework, appInfo.Architecture, appInfo.ExecutionId); + if (!VSTestTrace.TraceEnabled) return; foreach (var property in args.Handshake.Properties) @@ -143,6 +242,16 @@ private void OnHandshakeReceived(object sender, HandshakeArgs args) private void OnDiscoveredTestsReceived(object sender, DiscoveredTestEventArgs args) { + var testApp = (TestApplication)sender; + var appInfo = _executions[testApp]; + + foreach (var test in args.DiscoveredTests) + { + _output.TestDiscovered(appInfo.ModulePath, appInfo.TargetFramework, appInfo.Architecture, appInfo.ExecutionId, + test.DisplayName, + test.Uid); + } + if (!VSTestTrace.TraceEnabled) return; VSTestTrace.SafeWriteTrace(() => $"DiscoveredTests Execution Id: {args.ExecutionId}"); @@ -154,6 +263,40 @@ private void OnDiscoveredTestsReceived(object sender, DiscoveredTestEventArgs ar private void OnTestResultsReceived(object sender, TestResultEventArgs args) { + foreach (var testResult in args.SuccessfulTestResults) + { + var testApp = (TestApplication)sender; + var appInfo = _executions[testApp]; + _output.TestCompleted(appInfo.ModulePath, appInfo.TargetFramework, appInfo.Architecture, appInfo.ExecutionId, + testResult.Uid, + testResult.DisplayName, + ToOutcome(testResult.State), + TimeSpan.FromTicks(testResult.Duration ?? 0), + exceptions: null, + expected: null, + actual: null, + standardOutput: null, + errorOutput: null); + } + + foreach (var testResult in args.FailedTestResults) + { + var testApp = (TestApplication)sender; + // TODO: expected + // TODO: actual + var appInfo = _executions[testApp]; + _output.TestCompleted(appInfo.ModulePath, appInfo.TargetFramework, appInfo.Architecture, appInfo.ExecutionId, + testResult.Uid, + testResult.DisplayName, + ToOutcome(testResult.State), + TimeSpan.FromTicks(testResult.Duration ?? 0), + exceptions: testResult.Exceptions.Select(fe => new Microsoft.Testing.Platform.OutputDevice.Terminal.FlatException(fe.ErrorMessage, fe.ErrorType, fe.StackTrace)).ToArray(), + expected: null, + actual: null, + standardOutput: null, + errorOutput: null); + } + if (!VSTestTrace.TraceEnabled) return; VSTestTrace.SafeWriteTrace(() => $"TestResults Execution Id: {args.ExecutionId}"); @@ -168,13 +311,39 @@ private void OnTestResultsReceived(object sender, TestResultEventArgs args) foreach (FailedTestResult failedTestResult in args.FailedTestResults) { VSTestTrace.SafeWriteTrace(() => $"FailedTestResult: {failedTestResult.Uid}, {failedTestResult.DisplayName}, " + - $"{failedTestResult.State}, {failedTestResult.Duration}, {failedTestResult.Reason}, {failedTestResult.ErrorMessage}," + - $"{failedTestResult.ErrorStackTrace}, {failedTestResult.StandardOutput}, {failedTestResult.ErrorOutput}, {failedTestResult.SessionUid}"); + $"{failedTestResult.State}, {failedTestResult.Duration}, {failedTestResult.Reason}, {string.Join(", ", failedTestResult.Exceptions?.Select(e => $"{e.ErrorMessage}, {e.ErrorType}, {e.StackTrace}"))}" + + $"{failedTestResult.StandardOutput}, {failedTestResult.ErrorOutput}, {failedTestResult.SessionUid}"); } } + public static TestOutcome ToOutcome(byte? testState) + { + return testState switch + { + TestStates.Passed => TestOutcome.Passed, + TestStates.Skipped => TestOutcome.Skipped, + TestStates.Failed => TestOutcome.Fail, + TestStates.Error => TestOutcome.Error, + TestStates.Timeout => TestOutcome.Timeout, + TestStates.Cancelled => TestOutcome.Canceled, + _ => throw new ArgumentOutOfRangeException(nameof(testState), $"Invalid test state value {testState}") + }; + } + private void OnFileArtifactsReceived(object sender, FileArtifactEventArgs args) { + var testApp = (TestApplication)sender; + var appInfo = _executions[testApp]; + + foreach (var artifact in args.FileArtifacts) + { + // TODO: Is artifact out of process + _output.ArtifactAdded( + outOfProcess: false, + appInfo.ModulePath, appInfo.TargetFramework, appInfo.Architecture, appInfo.ExecutionId, + artifact.TestDisplayName, artifact.FullPath); + } + if (!VSTestTrace.TraceEnabled) return; VSTestTrace.SafeWriteTrace(() => $"FileArtifactMessages Execution Id: {args.ExecutionId}"); @@ -204,6 +373,15 @@ private void OnErrorReceived(object sender, ErrorEventArgs args) private void OnTestProcessExited(object sender, TestProcessExitEventArgs args) { + var testApplication = (TestApplication)sender; + + // If the application exits too early we might not start the execution, + // e.g. if the parameter is incorrect. + if (_executions.TryGetValue(testApplication, out var appInfo)) + { + _output.AssemblyRunCompleted(appInfo.ModulePath, appInfo.TargetFramework, appInfo.Architecture, appInfo.ExecutionId, args.ExitCode, string.Join(Environment.NewLine, args.OutputData), string.Join(Environment.NewLine, args.ErrorData)); + } + if (!VSTestTrace.TraceEnabled) return; if (args.ExitCode != ExitCodes.Success) diff --git a/src/Cli/dotnet/commands/dotnet-test/TestingPlatformOptions.cs b/src/Cli/dotnet/commands/dotnet-test/TestingPlatformOptions.cs index 6e3b6e0e5484..dda8123146ba 100644 --- a/src/Cli/dotnet/commands/dotnet-test/TestingPlatformOptions.cs +++ b/src/Cli/dotnet/commands/dotnet-test/TestingPlatformOptions.cs @@ -58,6 +58,12 @@ internal static class TestingPlatformOptions Arity = ArgumentArity.ExactlyOne }; + public static readonly CliOption ListTestsOption = new("--list-tests") + { + Description = LocalizableStrings.CmdListTestsDescription, + Arity = ArgumentArity.Zero + }; + public static readonly CliOption SolutionOption = new("--solution") { Description = LocalizableStrings.CmdSolutionDescription, diff --git a/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.cs.xlf b/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.cs.xlf index a9fbaefb534a..1a467d0ffc2e 100644 --- a/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.cs.xlf +++ b/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.cs.xlf @@ -2,6 +2,26 @@ + + Aborted + Aborted + + + + {0} tests running + {0} tests running + + + + and {0} more + and {0} more + + + + Actual + Actual + + .NET Test Driver Testovací ovladač .NET @@ -12,6 +32,16 @@ Testovací ovladač pro platformu .NET + + canceled + canceled + + + + Canceling the test session... + Canceling the test session... + + The additional msbuild parameters to pass. Další parametry msbuild, které se mají předat. @@ -315,11 +345,71 @@ Pokud zadaný adresář neexistuje, bude vytvořen. Test application(s) that support VSTest are not supported. + + Console is already in batching mode. + Console is already in batching mode. + Exception that is thrown when console is already collecting input into a batch (into a string builder), and code asks to enable batching mode again. + DUMP_TYPE DUMP_TYPE + + Discovered {0} tests in assembly + Discovered {0} tests in assembly + 0 is count, the sentence is followed by the path of the assebly + + + Discovering tests from + Discovering tests from + + + + Exit code + Exit code + + + + Expected + Expected + + + + Failed + Failed + + + + failed + failed + + + + failed with {0} error(s) + failed with {0} error(s) + + + + failed with {0} error(s) and {1} warning(s) + failed with {0} error(s) and {1} warning(s) + + + + failed with {0} warning(s) + failed with {0} warning(s) + + + + For test + For test + is followed by test name + + + from + from + from followed by a file name to point to the file from which test is originating + DUMP_TYPE DUMP_TYPE @@ -335,6 +425,16 @@ Pokud zadaný adresář neexistuje, bude vytvořen. Následující argumenty se ignorovaly: {0} + + In process file artifacts produced: + In process file artifacts produced: + + + + Minimum expected tests policy violation, tests ran {0}, minimum expected {1} + Minimum expected tests policy violation, tests ran {0}, minimum expected {1} + {0}, {1} number of tests + No serializer registered with ID '{0}' Není zaregistrovaný žádný serializátor s ID {0}. @@ -345,6 +445,21 @@ Pokud zadaný adresář neexistuje, bude vytvořen. Není zaregistrovaný žádný serializátor s typem {0}. + + Out of process file artifacts produced: + Out of process file artifacts produced: + + + + Passed + Passed + + + + passed + passed + + @@ -362,6 +477,56 @@ Argumenty RunSettings: Příklad: dotnet test -- MSTest.DeploymentEnabled=false MSTest.MapInconclusiveToFailed=True + + Running tests from + Running tests from + + + + skipped + skipped + + + + at + at + at that is used for a stack frame location in a stack trace, is followed by a class and method name + + + in + in + in that is used in stack frame it is followed by file name + + + Error output + Error output + + + + Standard output + Standard output + + + + Discovered {0} tests in {1} assemblies. + Discovered {0} tests in {1} assemblies. + 0 is number of tests, 1 is count of assemblies + + + Discovered {0} tests. + Discovered {0} tests. + 0 is number of tests + + + Test run summary: + Test run summary: + + + + Zero tests ran + Zero tests ran + + DATA_COLLECTOR_NAME DATA_COLLECTOR_NAME diff --git a/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.de.xlf b/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.de.xlf index 4e49829eae4b..7f363065066d 100644 --- a/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.de.xlf +++ b/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.de.xlf @@ -2,6 +2,26 @@ + + Aborted + Aborted + + + + {0} tests running + {0} tests running + + + + and {0} more + and {0} more + + + + Actual + Actual + + .NET Test Driver .NET-Testtreiber @@ -12,6 +32,16 @@ Testtreiber für die .NET-Plattform + + canceled + canceled + + + + Canceling the test session... + Canceling the test session... + + The additional msbuild parameters to pass. Die zusätzlichen MSBuild-Parameter, die weitergegeben werden sollen. @@ -315,11 +345,71 @@ Das angegebene Verzeichnis wird erstellt, wenn es nicht vorhanden ist. Test application(s) that support VSTest are not supported. + + Console is already in batching mode. + Console is already in batching mode. + Exception that is thrown when console is already collecting input into a batch (into a string builder), and code asks to enable batching mode again. + DUMP_TYPE DUMP_TYPE + + Discovered {0} tests in assembly + Discovered {0} tests in assembly + 0 is count, the sentence is followed by the path of the assebly + + + Discovering tests from + Discovering tests from + + + + Exit code + Exit code + + + + Expected + Expected + + + + Failed + Failed + + + + failed + failed + + + + failed with {0} error(s) + failed with {0} error(s) + + + + failed with {0} error(s) and {1} warning(s) + failed with {0} error(s) and {1} warning(s) + + + + failed with {0} warning(s) + failed with {0} warning(s) + + + + For test + For test + is followed by test name + + + from + from + from followed by a file name to point to the file from which test is originating + DUMP_TYPE DUMP_TYPE @@ -335,6 +425,16 @@ Das angegebene Verzeichnis wird erstellt, wenn es nicht vorhanden ist. Die folgenden Argumente wurden ignoriert: {0} + + In process file artifacts produced: + In process file artifacts produced: + + + + Minimum expected tests policy violation, tests ran {0}, minimum expected {1} + Minimum expected tests policy violation, tests ran {0}, minimum expected {1} + {0}, {1} number of tests + No serializer registered with ID '{0}' Es ist kein Serialisierungsmodul mit der ID "{0}" registriert @@ -345,6 +445,21 @@ Das angegebene Verzeichnis wird erstellt, wenn es nicht vorhanden ist. Es ist kein Serialisierungsmodul mit dem Typ "{0}" registriert + + Out of process file artifacts produced: + Out of process file artifacts produced: + + + + Passed + Passed + + + + passed + passed + + @@ -362,6 +477,56 @@ RunSettings-Argumente: Beispiel: "dotnet test -- MSTest.DeploymentEnabled=false MSTest.MapInconclusiveToFailed=True" + + Running tests from + Running tests from + + + + skipped + skipped + + + + at + at + at that is used for a stack frame location in a stack trace, is followed by a class and method name + + + in + in + in that is used in stack frame it is followed by file name + + + Error output + Error output + + + + Standard output + Standard output + + + + Discovered {0} tests in {1} assemblies. + Discovered {0} tests in {1} assemblies. + 0 is number of tests, 1 is count of assemblies + + + Discovered {0} tests. + Discovered {0} tests. + 0 is number of tests + + + Test run summary: + Test run summary: + + + + Zero tests ran + Zero tests ran + + DATA_COLLECTOR_NAME DATA_COLLECTOR_NAME diff --git a/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.es.xlf b/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.es.xlf index 2c8e4e80afcd..759ad3d5c656 100644 --- a/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.es.xlf +++ b/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.es.xlf @@ -2,6 +2,26 @@ + + Aborted + Aborted + + + + {0} tests running + {0} tests running + + + + and {0} more + and {0} more + + + + Actual + Actual + + .NET Test Driver Controlador de pruebas de .NET @@ -12,6 +32,16 @@ Controlador de pruebas para la plataforma .NET + + canceled + canceled + + + + Canceling the test session... + Canceling the test session... + + The additional msbuild parameters to pass. Parámetros adicionales de msbuild que se van a pasar. @@ -317,11 +347,71 @@ Si no existe, se creará el directorio especificado. Test application(s) that support VSTest are not supported. + + Console is already in batching mode. + Console is already in batching mode. + Exception that is thrown when console is already collecting input into a batch (into a string builder), and code asks to enable batching mode again. + DUMP_TYPE DUMP_TYPE + + Discovered {0} tests in assembly + Discovered {0} tests in assembly + 0 is count, the sentence is followed by the path of the assebly + + + Discovering tests from + Discovering tests from + + + + Exit code + Exit code + + + + Expected + Expected + + + + Failed + Failed + + + + failed + failed + + + + failed with {0} error(s) + failed with {0} error(s) + + + + failed with {0} error(s) and {1} warning(s) + failed with {0} error(s) and {1} warning(s) + + + + failed with {0} warning(s) + failed with {0} warning(s) + + + + For test + For test + is followed by test name + + + from + from + from followed by a file name to point to the file from which test is originating + DUMP_TYPE DUMP_TYPE @@ -337,6 +427,16 @@ Si no existe, se creará el directorio especificado. Se han omitido los argumentos siguientes: "{0}" + + In process file artifacts produced: + In process file artifacts produced: + + + + Minimum expected tests policy violation, tests ran {0}, minimum expected {1} + Minimum expected tests policy violation, tests ran {0}, minimum expected {1} + {0}, {1} number of tests + No serializer registered with ID '{0}' No hay ningún serializador registrado con el id. '{0}' @@ -347,6 +447,21 @@ Si no existe, se creará el directorio especificado. No hay ningún serializador registrado con el tipo '{0}' + + Out of process file artifacts produced: + Out of process file artifacts produced: + + + + Passed + Passed + + + + passed + passed + + @@ -364,6 +479,56 @@ Argumentos RunSettings: Ejemplo: dotnet test -- MSTest.DeploymentEnabled=false MSTest.MapInconclusiveToFailed=True + + Running tests from + Running tests from + + + + skipped + skipped + + + + at + at + at that is used for a stack frame location in a stack trace, is followed by a class and method name + + + in + in + in that is used in stack frame it is followed by file name + + + Error output + Error output + + + + Standard output + Standard output + + + + Discovered {0} tests in {1} assemblies. + Discovered {0} tests in {1} assemblies. + 0 is number of tests, 1 is count of assemblies + + + Discovered {0} tests. + Discovered {0} tests. + 0 is number of tests + + + Test run summary: + Test run summary: + + + + Zero tests ran + Zero tests ran + + DATA_COLLECTOR_NAME DATA_COLLECTOR_NAME diff --git a/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.fr.xlf b/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.fr.xlf index 49049418f185..158ab5127a98 100644 --- a/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.fr.xlf +++ b/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.fr.xlf @@ -2,6 +2,26 @@ + + Aborted + Aborted + + + + {0} tests running + {0} tests running + + + + and {0} more + and {0} more + + + + Actual + Actual + + .NET Test Driver Pilote de test .NET @@ -12,6 +32,16 @@ Pilote de test pour la plateforme .NET + + canceled + canceled + + + + Canceling the test session... + Canceling the test session... + + The additional msbuild parameters to pass. Paramètres msbuild supplémentaires à transmettre. @@ -315,11 +345,71 @@ Le répertoire spécifié est créé, s'il n'existe pas déjà. Test application(s) that support VSTest are not supported. + + Console is already in batching mode. + Console is already in batching mode. + Exception that is thrown when console is already collecting input into a batch (into a string builder), and code asks to enable batching mode again. + DUMP_TYPE DUMP_TYPE + + Discovered {0} tests in assembly + Discovered {0} tests in assembly + 0 is count, the sentence is followed by the path of the assebly + + + Discovering tests from + Discovering tests from + + + + Exit code + Exit code + + + + Expected + Expected + + + + Failed + Failed + + + + failed + failed + + + + failed with {0} error(s) + failed with {0} error(s) + + + + failed with {0} error(s) and {1} warning(s) + failed with {0} error(s) and {1} warning(s) + + + + failed with {0} warning(s) + failed with {0} warning(s) + + + + For test + For test + is followed by test name + + + from + from + from followed by a file name to point to the file from which test is originating + DUMP_TYPE DUMP_TYPE @@ -335,6 +425,16 @@ Le répertoire spécifié est créé, s'il n'existe pas déjà. Les arguments suivants ont été ignorés : "{0}" + + In process file artifacts produced: + In process file artifacts produced: + + + + Minimum expected tests policy violation, tests ran {0}, minimum expected {1} + Minimum expected tests policy violation, tests ran {0}, minimum expected {1} + {0}, {1} number of tests + No serializer registered with ID '{0}' Aucun sérialiseur inscrit avec l’ID « {0} » @@ -345,6 +445,21 @@ Le répertoire spécifié est créé, s'il n'existe pas déjà. Aucun sérialiseur inscrit avec le type « {0} » + + Out of process file artifacts produced: + Out of process file artifacts produced: + + + + Passed + Passed + + + + passed + passed + + @@ -362,6 +477,56 @@ Arguments de RunSettings : Exemple : dotnet test -- MSTest.DeploymentEnabled=false MSTest.MapInconclusiveToFailed=True + + Running tests from + Running tests from + + + + skipped + skipped + + + + at + at + at that is used for a stack frame location in a stack trace, is followed by a class and method name + + + in + in + in that is used in stack frame it is followed by file name + + + Error output + Error output + + + + Standard output + Standard output + + + + Discovered {0} tests in {1} assemblies. + Discovered {0} tests in {1} assemblies. + 0 is number of tests, 1 is count of assemblies + + + Discovered {0} tests. + Discovered {0} tests. + 0 is number of tests + + + Test run summary: + Test run summary: + + + + Zero tests ran + Zero tests ran + + DATA_COLLECTOR_NAME DATA_COLLECTOR_NAME diff --git a/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.it.xlf b/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.it.xlf index 3cd092f61652..19be826da372 100644 --- a/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.it.xlf +++ b/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.it.xlf @@ -2,6 +2,26 @@ + + Aborted + Aborted + + + + {0} tests running + {0} tests running + + + + and {0} more + and {0} more + + + + Actual + Actual + + .NET Test Driver Driver di test .NET @@ -12,6 +32,16 @@ Driver di test per la piattaforma .NET + + canceled + canceled + + + + Canceling the test session... + Canceling the test session... + + The additional msbuild parameters to pass. Parametri msbuild aggiuntivi da passare. @@ -315,11 +345,71 @@ Se non esiste, la directory specificata verrà creata. Test application(s) that support VSTest are not supported. + + Console is already in batching mode. + Console is already in batching mode. + Exception that is thrown when console is already collecting input into a batch (into a string builder), and code asks to enable batching mode again. + DUMP_TYPE DUMP_TYPE + + Discovered {0} tests in assembly + Discovered {0} tests in assembly + 0 is count, the sentence is followed by the path of the assebly + + + Discovering tests from + Discovering tests from + + + + Exit code + Exit code + + + + Expected + Expected + + + + Failed + Failed + + + + failed + failed + + + + failed with {0} error(s) + failed with {0} error(s) + + + + failed with {0} error(s) and {1} warning(s) + failed with {0} error(s) and {1} warning(s) + + + + failed with {0} warning(s) + failed with {0} warning(s) + + + + For test + For test + is followed by test name + + + from + from + from followed by a file name to point to the file from which test is originating + DUMP_TYPE DUMP_TYPE @@ -335,6 +425,16 @@ Se non esiste, la directory specificata verrà creata. Gli argomenti seguenti sono stati ignorati: "{0}" + + In process file artifacts produced: + In process file artifacts produced: + + + + Minimum expected tests policy violation, tests ran {0}, minimum expected {1} + Minimum expected tests policy violation, tests ran {0}, minimum expected {1} + {0}, {1} number of tests + No serializer registered with ID '{0}' Nessun serializzatore registrato con ID '{0}' @@ -345,6 +445,21 @@ Se non esiste, la directory specificata verrà creata. Nessun serializzatore registrato con tipo '{0}' + + Out of process file artifacts produced: + Out of process file artifacts produced: + + + + Passed + Passed + + + + passed + passed + + @@ -362,6 +477,56 @@ Argomenti di RunSettings: Esempio: dotnet test -- MSTest.DeploymentEnabled=false MSTest.MapInconclusiveToFailed=True + + Running tests from + Running tests from + + + + skipped + skipped + + + + at + at + at that is used for a stack frame location in a stack trace, is followed by a class and method name + + + in + in + in that is used in stack frame it is followed by file name + + + Error output + Error output + + + + Standard output + Standard output + + + + Discovered {0} tests in {1} assemblies. + Discovered {0} tests in {1} assemblies. + 0 is number of tests, 1 is count of assemblies + + + Discovered {0} tests. + Discovered {0} tests. + 0 is number of tests + + + Test run summary: + Test run summary: + + + + Zero tests ran + Zero tests ran + + DATA_COLLECTOR_NAME DATA_COLLECTOR_NAME diff --git a/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.ja.xlf b/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.ja.xlf index 5f02efcee8de..2a42214138ea 100644 --- a/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.ja.xlf +++ b/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.ja.xlf @@ -2,6 +2,26 @@ + + Aborted + Aborted + + + + {0} tests running + {0} tests running + + + + and {0} more + and {0} more + + + + Actual + Actual + + .NET Test Driver .NET Test Driver @@ -12,6 +32,16 @@ .NET Platform 用テスト ドライバー + + canceled + canceled + + + + Canceling the test session... + Canceling the test session... + + The additional msbuild parameters to pass. 渡す追加の msbuild パラメーター。 @@ -315,11 +345,71 @@ The specified directory will be created if it does not exist. Test application(s) that support VSTest are not supported. + + Console is already in batching mode. + Console is already in batching mode. + Exception that is thrown when console is already collecting input into a batch (into a string builder), and code asks to enable batching mode again. + DUMP_TYPE DUMP_TYPE + + Discovered {0} tests in assembly + Discovered {0} tests in assembly + 0 is count, the sentence is followed by the path of the assebly + + + Discovering tests from + Discovering tests from + + + + Exit code + Exit code + + + + Expected + Expected + + + + Failed + Failed + + + + failed + failed + + + + failed with {0} error(s) + failed with {0} error(s) + + + + failed with {0} error(s) and {1} warning(s) + failed with {0} error(s) and {1} warning(s) + + + + failed with {0} warning(s) + failed with {0} warning(s) + + + + For test + For test + is followed by test name + + + from + from + from followed by a file name to point to the file from which test is originating + DUMP_TYPE DUMP_TYPE @@ -335,6 +425,16 @@ The specified directory will be created if it does not exist. 次の引数は無視されました: "{0}" + + In process file artifacts produced: + In process file artifacts produced: + + + + Minimum expected tests policy violation, tests ran {0}, minimum expected {1} + Minimum expected tests policy violation, tests ran {0}, minimum expected {1} + {0}, {1} number of tests + No serializer registered with ID '{0}' ID '{0}' で登録されたシリアライザーがありません @@ -345,6 +445,21 @@ The specified directory will be created if it does not exist. 型 '{0}' で登録されたシリアライザーがありません + + Out of process file artifacts produced: + Out of process file artifacts produced: + + + + Passed + Passed + + + + passed + passed + + @@ -362,6 +477,56 @@ RunSettings 引数: 例: dotnet test -- MSTest.DeploymentEnabled=false MSTest.MapInconclusiveToFailed=True + + Running tests from + Running tests from + + + + skipped + skipped + + + + at + at + at that is used for a stack frame location in a stack trace, is followed by a class and method name + + + in + in + in that is used in stack frame it is followed by file name + + + Error output + Error output + + + + Standard output + Standard output + + + + Discovered {0} tests in {1} assemblies. + Discovered {0} tests in {1} assemblies. + 0 is number of tests, 1 is count of assemblies + + + Discovered {0} tests. + Discovered {0} tests. + 0 is number of tests + + + Test run summary: + Test run summary: + + + + Zero tests ran + Zero tests ran + + DATA_COLLECTOR_NAME DATA_COLLECTOR_NAME diff --git a/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.ko.xlf b/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.ko.xlf index d8b479b39995..9d51e9159532 100644 --- a/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.ko.xlf +++ b/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.ko.xlf @@ -2,6 +2,26 @@ + + Aborted + Aborted + + + + {0} tests running + {0} tests running + + + + and {0} more + and {0} more + + + + Actual + Actual + + .NET Test Driver .NET 테스트 드라이버 @@ -12,6 +32,16 @@ .NET 플랫폼용 테스트 드라이버입니다. + + canceled + canceled + + + + Canceling the test session... + Canceling the test session... + + The additional msbuild parameters to pass. 전달할 추가 msbuild 매개 변수입니다. @@ -315,11 +345,71 @@ The specified directory will be created if it does not exist. Test application(s) that support VSTest are not supported. + + Console is already in batching mode. + Console is already in batching mode. + Exception that is thrown when console is already collecting input into a batch (into a string builder), and code asks to enable batching mode again. + DUMP_TYPE DUMP_TYPE + + Discovered {0} tests in assembly + Discovered {0} tests in assembly + 0 is count, the sentence is followed by the path of the assebly + + + Discovering tests from + Discovering tests from + + + + Exit code + Exit code + + + + Expected + Expected + + + + Failed + Failed + + + + failed + failed + + + + failed with {0} error(s) + failed with {0} error(s) + + + + failed with {0} error(s) and {1} warning(s) + failed with {0} error(s) and {1} warning(s) + + + + failed with {0} warning(s) + failed with {0} warning(s) + + + + For test + For test + is followed by test name + + + from + from + from followed by a file name to point to the file from which test is originating + DUMP_TYPE DUMP_TYPE @@ -335,6 +425,16 @@ The specified directory will be created if it does not exist. "{0}" 인수가 무시되었습니다. + + In process file artifacts produced: + In process file artifacts produced: + + + + Minimum expected tests policy violation, tests ran {0}, minimum expected {1} + Minimum expected tests policy violation, tests ran {0}, minimum expected {1} + {0}, {1} number of tests + No serializer registered with ID '{0}' ID '{0}'(으)로 등록된 직렬 변환기가 없습니다. @@ -345,6 +445,21 @@ The specified directory will be created if it does not exist. '{0}' 형식으로 등록된 직렬 변환기가 없습니다. + + Out of process file artifacts produced: + Out of process file artifacts produced: + + + + Passed + Passed + + + + passed + passed + + @@ -362,6 +477,56 @@ RunSettings 인수: 예: dotnet test -- MSTest.DeploymentEnabled=false MSTest.MapInconclusiveToFailed=True + + Running tests from + Running tests from + + + + skipped + skipped + + + + at + at + at that is used for a stack frame location in a stack trace, is followed by a class and method name + + + in + in + in that is used in stack frame it is followed by file name + + + Error output + Error output + + + + Standard output + Standard output + + + + Discovered {0} tests in {1} assemblies. + Discovered {0} tests in {1} assemblies. + 0 is number of tests, 1 is count of assemblies + + + Discovered {0} tests. + Discovered {0} tests. + 0 is number of tests + + + Test run summary: + Test run summary: + + + + Zero tests ran + Zero tests ran + + DATA_COLLECTOR_NAME DATA_COLLECTOR_NAME diff --git a/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.pl.xlf b/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.pl.xlf index b7a4dabc55f4..b4ebe3500c92 100644 --- a/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.pl.xlf +++ b/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.pl.xlf @@ -2,6 +2,26 @@ + + Aborted + Aborted + + + + {0} tests running + {0} tests running + + + + and {0} more + and {0} more + + + + Actual + Actual + + .NET Test Driver Sterownik testów platformy .NET @@ -12,6 +32,16 @@ Sterownik testów dla platformy .NET + + canceled + canceled + + + + Canceling the test session... + Canceling the test session... + + The additional msbuild parameters to pass. Dodatkowe parametry programu MSBuild do przekazania. @@ -315,11 +345,71 @@ Jeśli określony katalog nie istnieje, zostanie utworzony. Test application(s) that support VSTest are not supported. + + Console is already in batching mode. + Console is already in batching mode. + Exception that is thrown when console is already collecting input into a batch (into a string builder), and code asks to enable batching mode again. + DUMP_TYPE DUMP_TYPE + + Discovered {0} tests in assembly + Discovered {0} tests in assembly + 0 is count, the sentence is followed by the path of the assebly + + + Discovering tests from + Discovering tests from + + + + Exit code + Exit code + + + + Expected + Expected + + + + Failed + Failed + + + + failed + failed + + + + failed with {0} error(s) + failed with {0} error(s) + + + + failed with {0} error(s) and {1} warning(s) + failed with {0} error(s) and {1} warning(s) + + + + failed with {0} warning(s) + failed with {0} warning(s) + + + + For test + For test + is followed by test name + + + from + from + from followed by a file name to point to the file from which test is originating + DUMP_TYPE DUMP_TYPE @@ -335,6 +425,16 @@ Jeśli określony katalog nie istnieje, zostanie utworzony. Następujące argumenty zostały zignorowane: „{0}” + + In process file artifacts produced: + In process file artifacts produced: + + + + Minimum expected tests policy violation, tests ran {0}, minimum expected {1} + Minimum expected tests policy violation, tests ran {0}, minimum expected {1} + {0}, {1} number of tests + No serializer registered with ID '{0}' Nie zarejestrowano serializatora z identyfikatorem „{0}” @@ -345,6 +445,21 @@ Jeśli określony katalog nie istnieje, zostanie utworzony. Nie zarejestrowano serializatora z typem „{0}” + + Out of process file artifacts produced: + Out of process file artifacts produced: + + + + Passed + Passed + + + + passed + passed + + @@ -362,6 +477,56 @@ Argumenty RunSettings: Przykład: dotnet test -- MSTest.DeploymentEnabled=false MSTest.MapInconclusiveToFailed=True + + Running tests from + Running tests from + + + + skipped + skipped + + + + at + at + at that is used for a stack frame location in a stack trace, is followed by a class and method name + + + in + in + in that is used in stack frame it is followed by file name + + + Error output + Error output + + + + Standard output + Standard output + + + + Discovered {0} tests in {1} assemblies. + Discovered {0} tests in {1} assemblies. + 0 is number of tests, 1 is count of assemblies + + + Discovered {0} tests. + Discovered {0} tests. + 0 is number of tests + + + Test run summary: + Test run summary: + + + + Zero tests ran + Zero tests ran + + DATA_COLLECTOR_NAME DATA_COLLECTOR_NAME diff --git a/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.pt-BR.xlf b/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.pt-BR.xlf index 17f1821a040c..c4f3190d52e3 100644 --- a/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.pt-BR.xlf +++ b/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.pt-BR.xlf @@ -2,6 +2,26 @@ + + Aborted + Aborted + + + + {0} tests running + {0} tests running + + + + and {0} more + and {0} more + + + + Actual + Actual + + .NET Test Driver Driver de Teste do .NET @@ -12,6 +32,16 @@ Driver de Teste para a Plataforma .NET + + canceled + canceled + + + + Canceling the test session... + Canceling the test session... + + The additional msbuild parameters to pass. Os parâmetros msbuild adicionais a serem aprovados. @@ -315,11 +345,71 @@ O diretório especificado será criado se ele ainda não existir. Test application(s) that support VSTest are not supported. + + Console is already in batching mode. + Console is already in batching mode. + Exception that is thrown when console is already collecting input into a batch (into a string builder), and code asks to enable batching mode again. + DUMP_TYPE DUMP_TYPE + + Discovered {0} tests in assembly + Discovered {0} tests in assembly + 0 is count, the sentence is followed by the path of the assebly + + + Discovering tests from + Discovering tests from + + + + Exit code + Exit code + + + + Expected + Expected + + + + Failed + Failed + + + + failed + failed + + + + failed with {0} error(s) + failed with {0} error(s) + + + + failed with {0} error(s) and {1} warning(s) + failed with {0} error(s) and {1} warning(s) + + + + failed with {0} warning(s) + failed with {0} warning(s) + + + + For test + For test + is followed by test name + + + from + from + from followed by a file name to point to the file from which test is originating + DUMP_TYPE DUMP_TYPE @@ -335,6 +425,16 @@ O diretório especificado será criado se ele ainda não existir. Os argumentos a seguir foram ignorados: "{0}" + + In process file artifacts produced: + In process file artifacts produced: + + + + Minimum expected tests policy violation, tests ran {0}, minimum expected {1} + Minimum expected tests policy violation, tests ran {0}, minimum expected {1} + {0}, {1} number of tests + No serializer registered with ID '{0}' Nenhum serializador registrado com a ID '{0}' @@ -345,6 +445,21 @@ O diretório especificado será criado se ele ainda não existir. Nenhum serializador registrado com o tipo '{0}' + + Out of process file artifacts produced: + Out of process file artifacts produced: + + + + Passed + Passed + + + + passed + passed + + @@ -362,6 +477,56 @@ Argumentos de RunSettings: Exemplo: dotnet test -- MSTest.DeploymentEnabled=false MSTest.MapInconclusiveToFailed=True + + Running tests from + Running tests from + + + + skipped + skipped + + + + at + at + at that is used for a stack frame location in a stack trace, is followed by a class and method name + + + in + in + in that is used in stack frame it is followed by file name + + + Error output + Error output + + + + Standard output + Standard output + + + + Discovered {0} tests in {1} assemblies. + Discovered {0} tests in {1} assemblies. + 0 is number of tests, 1 is count of assemblies + + + Discovered {0} tests. + Discovered {0} tests. + 0 is number of tests + + + Test run summary: + Test run summary: + + + + Zero tests ran + Zero tests ran + + DATA_COLLECTOR_NAME DATA_COLLECTOR_NAME diff --git a/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.ru.xlf b/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.ru.xlf index 0180857c5c29..74c94acd1e44 100644 --- a/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.ru.xlf +++ b/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.ru.xlf @@ -2,6 +2,26 @@ + + Aborted + Aborted + + + + {0} tests running + {0} tests running + + + + and {0} more + and {0} more + + + + Actual + Actual + + .NET Test Driver Драйвер тестов .NET @@ -12,6 +32,16 @@ Драйвер тестов для платформы .NET + + canceled + canceled + + + + Canceling the test session... + Canceling the test session... + + The additional msbuild parameters to pass. Дополнительные передаваемые параметры msbuild. @@ -315,11 +345,71 @@ The specified directory will be created if it does not exist. Test application(s) that support VSTest are not supported. + + Console is already in batching mode. + Console is already in batching mode. + Exception that is thrown when console is already collecting input into a batch (into a string builder), and code asks to enable batching mode again. + DUMP_TYPE DUMP_TYPE + + Discovered {0} tests in assembly + Discovered {0} tests in assembly + 0 is count, the sentence is followed by the path of the assebly + + + Discovering tests from + Discovering tests from + + + + Exit code + Exit code + + + + Expected + Expected + + + + Failed + Failed + + + + failed + failed + + + + failed with {0} error(s) + failed with {0} error(s) + + + + failed with {0} error(s) and {1} warning(s) + failed with {0} error(s) and {1} warning(s) + + + + failed with {0} warning(s) + failed with {0} warning(s) + + + + For test + For test + is followed by test name + + + from + from + from followed by a file name to point to the file from which test is originating + DUMP_TYPE DUMP_TYPE @@ -335,6 +425,16 @@ The specified directory will be created if it does not exist. Следующие аргументы пропущены: "{0}" + + In process file artifacts produced: + In process file artifacts produced: + + + + Minimum expected tests policy violation, tests ran {0}, minimum expected {1} + Minimum expected tests policy violation, tests ran {0}, minimum expected {1} + {0}, {1} number of tests + No serializer registered with ID '{0}' Не зарегистрирован сериализатор с ИД "{0}" @@ -345,6 +445,21 @@ The specified directory will be created if it does not exist. Не зарегистрирован сериализатор с типом "{0}" + + Out of process file artifacts produced: + Out of process file artifacts produced: + + + + Passed + Passed + + + + passed + passed + + @@ -362,6 +477,56 @@ RunSettings arguments: Пример: dotnet test -- MSTest.DeploymentEnabled=false MSTest.MapInconclusiveToFailed=True + + Running tests from + Running tests from + + + + skipped + skipped + + + + at + at + at that is used for a stack frame location in a stack trace, is followed by a class and method name + + + in + in + in that is used in stack frame it is followed by file name + + + Error output + Error output + + + + Standard output + Standard output + + + + Discovered {0} tests in {1} assemblies. + Discovered {0} tests in {1} assemblies. + 0 is number of tests, 1 is count of assemblies + + + Discovered {0} tests. + Discovered {0} tests. + 0 is number of tests + + + Test run summary: + Test run summary: + + + + Zero tests ran + Zero tests ran + + DATA_COLLECTOR_NAME DATA_COLLECTOR_NAME diff --git a/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.tr.xlf b/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.tr.xlf index 2f0659ae58cd..2f0947f5b3f6 100644 --- a/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.tr.xlf +++ b/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.tr.xlf @@ -2,6 +2,26 @@ + + Aborted + Aborted + + + + {0} tests running + {0} tests running + + + + and {0} more + and {0} more + + + + Actual + Actual + + .NET Test Driver .NET Test Sürücüsü @@ -12,6 +32,16 @@ .NET Platformunun Test Sürücüsü + + canceled + canceled + + + + Canceling the test session... + Canceling the test session... + + The additional msbuild parameters to pass. Geçirilecek ek msbuild parametreleri. @@ -315,11 +345,71 @@ Belirtilen dizin yoksa oluşturulur. Test application(s) that support VSTest are not supported. + + Console is already in batching mode. + Console is already in batching mode. + Exception that is thrown when console is already collecting input into a batch (into a string builder), and code asks to enable batching mode again. + DUMP_TYPE DÖKÜM_TÜRÜ + + Discovered {0} tests in assembly + Discovered {0} tests in assembly + 0 is count, the sentence is followed by the path of the assebly + + + Discovering tests from + Discovering tests from + + + + Exit code + Exit code + + + + Expected + Expected + + + + Failed + Failed + + + + failed + failed + + + + failed with {0} error(s) + failed with {0} error(s) + + + + failed with {0} error(s) and {1} warning(s) + failed with {0} error(s) and {1} warning(s) + + + + failed with {0} warning(s) + failed with {0} warning(s) + + + + For test + For test + is followed by test name + + + from + from + from followed by a file name to point to the file from which test is originating + DUMP_TYPE DÖKÜM_TÜRÜ @@ -335,6 +425,16 @@ Belirtilen dizin yoksa oluşturulur. Şu bağımsız değişkenler yoksayıldı: "{0}" + + In process file artifacts produced: + In process file artifacts produced: + + + + Minimum expected tests policy violation, tests ran {0}, minimum expected {1} + Minimum expected tests policy violation, tests ran {0}, minimum expected {1} + {0}, {1} number of tests + No serializer registered with ID '{0}' '{0}' kimliğiyle kayıtlı seri hale getirici yok @@ -345,6 +445,21 @@ Belirtilen dizin yoksa oluşturulur. '{0}' türüyle kayıtlı seri hale getirici yok + + Out of process file artifacts produced: + Out of process file artifacts produced: + + + + Passed + Passed + + + + passed + passed + + @@ -362,6 +477,56 @@ RunSettings bağımsız değişkenleri: Örnek: dotnet test -- MSTest.DeploymentEnabled=false MSTest.MapInconclusiveToFailed=True + + Running tests from + Running tests from + + + + skipped + skipped + + + + at + at + at that is used for a stack frame location in a stack trace, is followed by a class and method name + + + in + in + in that is used in stack frame it is followed by file name + + + Error output + Error output + + + + Standard output + Standard output + + + + Discovered {0} tests in {1} assemblies. + Discovered {0} tests in {1} assemblies. + 0 is number of tests, 1 is count of assemblies + + + Discovered {0} tests. + Discovered {0} tests. + 0 is number of tests + + + Test run summary: + Test run summary: + + + + Zero tests ran + Zero tests ran + + DATA_COLLECTOR_NAME DATA_COLLECTOR_NAME diff --git a/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.zh-Hans.xlf b/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.zh-Hans.xlf index b178d24834a7..860e119ca0fc 100644 --- a/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.zh-Hans.xlf +++ b/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.zh-Hans.xlf @@ -2,6 +2,26 @@ + + Aborted + Aborted + + + + {0} tests running + {0} tests running + + + + and {0} more + and {0} more + + + + Actual + Actual + + .NET Test Driver .NET 测试驱动程序 @@ -12,6 +32,16 @@ 适用于 .NET 平台的测试驱动程序 + + canceled + canceled + + + + Canceling the test session... + Canceling the test session... + + The additional msbuild parameters to pass. 要传递的其他 msbuild 参数。 @@ -315,11 +345,71 @@ The specified directory will be created if it does not exist. Test application(s) that support VSTest are not supported. + + Console is already in batching mode. + Console is already in batching mode. + Exception that is thrown when console is already collecting input into a batch (into a string builder), and code asks to enable batching mode again. + DUMP_TYPE DUMP_TYPE + + Discovered {0} tests in assembly + Discovered {0} tests in assembly + 0 is count, the sentence is followed by the path of the assebly + + + Discovering tests from + Discovering tests from + + + + Exit code + Exit code + + + + Expected + Expected + + + + Failed + Failed + + + + failed + failed + + + + failed with {0} error(s) + failed with {0} error(s) + + + + failed with {0} error(s) and {1} warning(s) + failed with {0} error(s) and {1} warning(s) + + + + failed with {0} warning(s) + failed with {0} warning(s) + + + + For test + For test + is followed by test name + + + from + from + from followed by a file name to point to the file from which test is originating + DUMP_TYPE DUMP_TYPE @@ -335,6 +425,16 @@ The specified directory will be created if it does not exist. 已忽略以下参数:“{0}” + + In process file artifacts produced: + In process file artifacts produced: + + + + Minimum expected tests policy violation, tests ran {0}, minimum expected {1} + Minimum expected tests policy violation, tests ran {0}, minimum expected {1} + {0}, {1} number of tests + No serializer registered with ID '{0}' 没有使用 ID“{0}”注册序列化程序 @@ -345,6 +445,21 @@ The specified directory will be created if it does not exist. 没有使用类型“{0}”注册序列化程序 + + Out of process file artifacts produced: + Out of process file artifacts produced: + + + + Passed + Passed + + + + passed + passed + + @@ -362,6 +477,56 @@ RunSettings 参数: 示例: dotnet test -- MSTest.DeploymentEnabled=false MSTest.MapInconclusiveToFailed=True + + Running tests from + Running tests from + + + + skipped + skipped + + + + at + at + at that is used for a stack frame location in a stack trace, is followed by a class and method name + + + in + in + in that is used in stack frame it is followed by file name + + + Error output + Error output + + + + Standard output + Standard output + + + + Discovered {0} tests in {1} assemblies. + Discovered {0} tests in {1} assemblies. + 0 is number of tests, 1 is count of assemblies + + + Discovered {0} tests. + Discovered {0} tests. + 0 is number of tests + + + Test run summary: + Test run summary: + + + + Zero tests ran + Zero tests ran + + DATA_COLLECTOR_NAME DATA_COLLECTOR_NAME diff --git a/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.zh-Hant.xlf b/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.zh-Hant.xlf index bca91b0748e9..c5553741971d 100644 --- a/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.zh-Hant.xlf +++ b/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.zh-Hant.xlf @@ -2,6 +2,26 @@ + + Aborted + Aborted + + + + {0} tests running + {0} tests running + + + + and {0} more + and {0} more + + + + Actual + Actual + + .NET Test Driver .NET 測試驅動程式 @@ -12,6 +32,16 @@ 適用於 .NET 平台的測試驅動程式 + + canceled + canceled + + + + Canceling the test session... + Canceling the test session... + + The additional msbuild parameters to pass. 要傳遞的其他 msbuild 參數。 @@ -315,11 +345,71 @@ The specified directory will be created if it does not exist. Test application(s) that support VSTest are not supported. + + Console is already in batching mode. + Console is already in batching mode. + Exception that is thrown when console is already collecting input into a batch (into a string builder), and code asks to enable batching mode again. + DUMP_TYPE DUMP_TYPE + + Discovered {0} tests in assembly + Discovered {0} tests in assembly + 0 is count, the sentence is followed by the path of the assebly + + + Discovering tests from + Discovering tests from + + + + Exit code + Exit code + + + + Expected + Expected + + + + Failed + Failed + + + + failed + failed + + + + failed with {0} error(s) + failed with {0} error(s) + + + + failed with {0} error(s) and {1} warning(s) + failed with {0} error(s) and {1} warning(s) + + + + failed with {0} warning(s) + failed with {0} warning(s) + + + + For test + For test + is followed by test name + + + from + from + from followed by a file name to point to the file from which test is originating + DUMP_TYPE DUMP_TYPE @@ -335,6 +425,16 @@ The specified directory will be created if it does not exist. 已忽略下列引數: "{0}" + + In process file artifacts produced: + In process file artifacts produced: + + + + Minimum expected tests policy violation, tests ran {0}, minimum expected {1} + Minimum expected tests policy violation, tests ran {0}, minimum expected {1} + {0}, {1} number of tests + No serializer registered with ID '{0}' 沒有使用識別碼 '{0}' 註冊的序列化程式 @@ -345,6 +445,21 @@ The specified directory will be created if it does not exist. 沒有使用類型 '{0}' 註冊的序列化程式 + + Out of process file artifacts produced: + Out of process file artifacts produced: + + + + Passed + Passed + + + + passed + passed + + @@ -362,6 +477,56 @@ RunSettings 引數: 範例: dotnet test -- MSTest.DeploymentEnabled=false MSTest.MapInconclusiveToFailed=True + + Running tests from + Running tests from + + + + skipped + skipped + + + + at + at + at that is used for a stack frame location in a stack trace, is followed by a class and method name + + + in + in + in that is used in stack frame it is followed by file name + + + Error output + Error output + + + + Standard output + Standard output + + + + Discovered {0} tests in {1} assemblies. + Discovered {0} tests in {1} assemblies. + 0 is number of tests, 1 is count of assemblies + + + Discovered {0} tests. + Discovered {0} tests. + 0 is number of tests + + + Test run summary: + Test run summary: + + + + Zero tests ran + Zero tests ran + + DATA_COLLECTOR_NAME DATA_COLLECTOR_NAME