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