diff --git a/src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/AnsiTerminal.cs b/src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/AnsiTerminal.cs index 9c9341357c..21a664fbb4 100644 --- a/src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/AnsiTerminal.cs +++ b/src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/AnsiTerminal.cs @@ -45,7 +45,7 @@ internal sealed class AnsiTerminal : ITerminal private readonly bool _useBusyIndicator; private readonly StringBuilder _stringBuilder = new(); private bool _isBatching; - private AnsiTerminalTestProgressFrame _currentFrame = new(Array.Empty(), 0, 0); + private AnsiTerminalTestProgressFrame _currentFrame = new(0, 0); public AnsiTerminal(IConsole console, string? baseDirectory) { @@ -274,27 +274,20 @@ public void SetCursorHorizontal(int position) /// public void EraseProgress() { - if (_currentFrame.ProgressCount == 0) + if (_currentFrame.RenderedLines == null || _currentFrame.RenderedLines.Count == 0) { return; } - AppendLine($"{AnsiCodes.CSI}{_currentFrame.ProgressCount + 2}{AnsiCodes.MoveUpToLineStart}"); + AppendLine($"{AnsiCodes.CSI}{_currentFrame.RenderedLines.Count + 2}{AnsiCodes.MoveUpToLineStart}"); Append($"{AnsiCodes.CSI}{AnsiCodes.EraseInDisplay}"); _currentFrame.Clear(); } public void RenderProgress(TestProgressState?[] progress) { - AnsiTerminalTestProgressFrame newFrame = new(progress, Width, Height); - - // Do not render delta but clear everything if Terminal width or height have changed. - if (newFrame.Width != _currentFrame.Width || newFrame.Height != _currentFrame.Height) - { - EraseProgress(); - } - - newFrame.Render(_currentFrame, this); + AnsiTerminalTestProgressFrame newFrame = new(Width, Height); + newFrame.Render(_currentFrame, progress, terminal: this); _currentFrame = newFrame; } diff --git a/src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/AnsiTerminalTestProgressFrame.cs b/src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/AnsiTerminalTestProgressFrame.cs index c2b3d7da46..35cd1f7e38 100644 --- a/src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/AnsiTerminalTestProgressFrame.cs +++ b/src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/AnsiTerminalTestProgressFrame.cs @@ -12,43 +12,29 @@ internal sealed class AnsiTerminalTestProgressFrame { private const int MaxColumn = 250; - private readonly (TestProgressState TestProgressState, int DurationLength)[] _progressItems; - public int Width { get; } public int Height { get; } - public int ProgressCount { get; private set; } + public List? RenderedLines { get; set; } - public AnsiTerminalTestProgressFrame(TestProgressState?[] nodes, int width, int height) + public AnsiTerminalTestProgressFrame(int width, int height) { Width = Math.Min(width, MaxColumn); Height = height; - - _progressItems = new (TestProgressState, int)[nodes.Length]; - - foreach (TestProgressState? status in nodes) - { - if (status is not null) - { - _progressItems[ProgressCount++].TestProgressState = status; - } - } } - public void AppendTestWorkerProgress(int i, AnsiTerminal terminal) + public void AppendTestWorkerProgress(TestProgressState progress, RenderedProgressItem currentLine, AnsiTerminal terminal) { - TestProgressState p = _progressItems[i].TestProgressState; - - string durationString = HumanReadableDurationFormatter.Render(p.Stopwatch.Elapsed); + string durationString = HumanReadableDurationFormatter.Render(progress.Stopwatch.Elapsed); - _progressItems[i].DurationLength = durationString.Length; + currentLine.RenderedDurationLength = durationString.Length; int nonReservedWidth = Width - (durationString.Length + 2); - int passed = p.PassedTests; - int failed = p.FailedTests; - int skipped = p.SkippedTests; + int passed = progress.PassedTests; + int failed = progress.FailedTests; + int skipped = progress.SkippedTests; int charsTaken = 0; terminal.Append('['); @@ -85,28 +71,27 @@ public void AppendTestWorkerProgress(int i, AnsiTerminal terminal) terminal.Append(']'); charsTaken++; - // -5 because we want to output at least 1 char from the name, and ' ...' followed by duration terminal.Append(' '); charsTaken++; - AppendToWidth(terminal, p.AssemblyName, nonReservedWidth, ref charsTaken); + AppendToWidth(terminal, progress.AssemblyName, nonReservedWidth, ref charsTaken); - if (charsTaken < nonReservedWidth && (p.TargetFramework != null || p.Architecture != null)) + if (charsTaken < nonReservedWidth && (progress.TargetFramework != null || progress.Architecture != null)) { int lengthNeeded = 0; lengthNeeded++; // for '(' - if (p.TargetFramework != null) + if (progress.TargetFramework != null) { - lengthNeeded += p.TargetFramework.Length; - if (p.Architecture != null) + lengthNeeded += progress.TargetFramework.Length; + if (progress.Architecture != null) { lengthNeeded++; // for '|' } } - if (p.Architecture != null) + if (progress.Architecture != null) { - lengthNeeded += p.Architecture.Length; + lengthNeeded += progress.Architecture.Length; } lengthNeeded++; // for ')' @@ -114,29 +99,41 @@ public void AppendTestWorkerProgress(int i, AnsiTerminal terminal) if ((charsTaken + lengthNeeded) < nonReservedWidth) { terminal.Append(" ("); - if (p.TargetFramework != null) + if (progress.TargetFramework != null) { - terminal.Append(p.TargetFramework); - if (p.Architecture != null) + terminal.Append(progress.TargetFramework); + if (progress.Architecture != null) { terminal.Append('|'); } } - if (p.Architecture != null) + if (progress.Architecture != null) { - terminal.Append(p.Architecture); + terminal.Append(progress.Architecture); } terminal.Append(')'); } } - if (!RoslynString.IsNullOrWhiteSpace(p.Detail)) - { - terminal.Append(" - "); - terminal.Append(p.Detail); - } + 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); @@ -166,60 +163,131 @@ private static void AppendToWidth(AnsiTerminal terminal, string text, int width, /// /// Render VT100 string to update from current to next frame. /// - public void Render(AnsiTerminalTestProgressFrame previousFrame, AnsiTerminal terminal) + public void Render(AnsiTerminalTestProgressFrame previousFrame, TestProgressState?[] progress, AnsiTerminal terminal) { - // Don't go up if we did not render progress in previous frame or we cleared it. - if (previousFrame.ProgressCount > 0) + // 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 prepend 1 empty line before the progress - // and new line after the progress indicator. - terminal.MoveCursorUp(previousFrame.ProgressCount + 2); + // + 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 (ProgressCount > 0) + if (progress.Length > 0) { terminal.AppendLine(); } int i = 0; - for (; i < ProgressCount; i++) + RenderedLines = new List(progress.Length * 2); + List progresses = GenerateLinesToRender(progress); + + foreach (object item in progresses) { - // Optimize the rendering. When we have previous frame to compare with, we can decide to rewrite only part of the screen, - // rather than deleting whole line and have the line flicker. Most commonly this will rewrite just the time part of the line. - if (previousFrame.ProgressCount > i) + if (previousFrame.RenderedLines != null && previousFrame.RenderedLines.Count > i) { - if (previousFrame._progressItems[i].TestProgressState.LastUpdate != _progressItems[i].TestProgressState.LastUpdate) + if (item is TestProgressState progressItem) { - // Same everything except time. - string durationString = HumanReadableDurationFormatter.Render(_progressItems[i].TestProgressState.Stopwatch.Elapsed); + var currentLine = new RenderedProgressItem(progressItem.Id, progressItem.Version); + RenderedLines.Add(currentLine); - if (previousFrame._progressItems[i].DurationLength == durationString.Length) + // 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) { - terminal.SetCursorHorizontal(MaxColumn); - terminal.Append($"{AnsiCodes.SetCursorHorizontal(MaxColumn)}{AnsiCodes.MoveCursorBackward(durationString.Length)}{durationString}"); - _progressItems[i].DurationLength = durationString.Length; + // 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 { - // Render full line. + // These lines are different or the line was updated. Render the whole line. terminal.Append($"{AnsiCodes.CSI}{AnsiCodes.EraseInLine}"); - AppendTestWorkerProgress(i, terminal); + AppendTestWorkerProgress(progressItem, currentLine, terminal); } } - else + + if (item is TestDetailState detailItem) { - // Render full line. - terminal.Append($"{AnsiCodes.CSI}{AnsiCodes.EraseInLine}"); - AppendTestWorkerProgress(i, terminal); + 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 { - // From now on we have to simply WriteLine - AppendTestWorkerProgress(i, terminal); + // 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 @@ -228,12 +296,57 @@ public void Render(AnsiTerminalTestProgressFrame previousFrame, AnsiTerminal ter terminal.AppendLine(); } - // clear no longer used lines - if (i < previousFrame.ProgressCount) + // We rendered more lines in previous frame. Clear them. + if (previousFrame.RenderedLines != null && i < previousFrame.RenderedLines.Count) { terminal.Append($"{AnsiCodes.CSI}{AnsiCodes.EraseInDisplay}"); } } - public void Clear() => ProgressCount = 0; + 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/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/HumanReadableDurationFormatter.cs b/src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/HumanReadableDurationFormatter.cs index 6d07e8d56b..27aa9af5d1 100644 --- a/src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/HumanReadableDurationFormatter.cs +++ b/src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/HumanReadableDurationFormatter.cs @@ -55,8 +55,13 @@ public static void Append(ITerminal terminal, TimeSpan duration, bool wrapInPare 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) + 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(); @@ -66,35 +71,35 @@ public static string Render(TimeSpan duration, bool wrapInParentheses = true, bo stringBuilder.Append('('); } - if (duration.Days > 0) + if (duration.Value.Days > 0) { - stringBuilder.Append(CultureInfo.CurrentCulture, $"{duration.Days}d"); + stringBuilder.Append(CultureInfo.CurrentCulture, $"{duration.Value.Days}d"); hasParentValue = true; } - if (duration.Hours > 0 || hasParentValue) + if (duration.Value.Hours > 0 || hasParentValue) { - stringBuilder.Append(GetFormattedPart(duration.Hours, hasParentValue, "h")); + stringBuilder.Append(GetFormattedPart(duration.Value.Hours, hasParentValue, "h")); hasParentValue = true; } - if (duration.Minutes > 0 || hasParentValue) + if (duration.Value.Minutes > 0 || hasParentValue) { - stringBuilder.Append(GetFormattedPart(duration.Minutes, hasParentValue, "m")); + stringBuilder.Append(GetFormattedPart(duration.Value.Minutes, hasParentValue, "m")); hasParentValue = true; } - if (duration.Seconds > 0 || hasParentValue || !showMilliseconds) + if (duration.Value.Seconds > 0 || hasParentValue || !showMilliseconds) { - stringBuilder.Append(GetFormattedPart(duration.Seconds, hasParentValue, "s")); + stringBuilder.Append(GetFormattedPart(duration.Value.Seconds, hasParentValue, "s")); hasParentValue = true; } if (showMilliseconds) { - if (duration.Milliseconds >= 0 || hasParentValue) + if (duration.Value.Milliseconds >= 0 || hasParentValue) { - stringBuilder.Append(GetFormattedPart(duration.Milliseconds, hasParentValue, "ms", paddingWitdh: 3)); + stringBuilder.Append(GetFormattedPart(duration.Value.Milliseconds, hasParentValue, "ms", paddingWitdh: 3)); } } diff --git a/src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/NonAnsiTerminal.cs b/src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/NonAnsiTerminal.cs index 8c1f545720..2da9ab982c 100644 --- a/src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/NonAnsiTerminal.cs +++ b/src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/NonAnsiTerminal.cs @@ -183,7 +183,8 @@ public void RenderProgress(TestProgressState?[] progress) // 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. - string? detail = !RoslynString.IsNullOrWhiteSpace(p.Detail) ? $"- {p.Detail}" : null; + TestDetailState? activeTest = p.TestNodeResultsState?.GetFirstRunningTask(); + string? detail = !RoslynString.IsNullOrWhiteSpace(activeTest?.Text) ? $"- {activeTest.Text}" : null; Append('['); SetColor(TerminalColor.DarkGreen); Append('+'); diff --git a/src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/TerminalTestReporter.cs b/src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/TerminalTestReporter.cs index 0c8543d807..44b067a99c 100644 --- a/src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/TerminalTestReporter.cs +++ b/src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/TerminalTestReporter.cs @@ -31,6 +31,18 @@ internal sealed partial class TerminalTestReporter : IDisposable 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(); @@ -108,10 +120,12 @@ private static Regex GetFrameRegex() } #endif + private int _counter; + /// /// Initializes a new instance of the class with custom terminal and manual refresh for testing. /// - internal TerminalTestReporter(IConsole console, TerminalTestReporterOptions options) + public TerminalTestReporter(IConsole console, TerminalTestReporterOptions options) { _options = options; @@ -168,7 +182,7 @@ private TestProgressState GetOrAddAssemblyRun(string assembly, string? targetFra return _assemblies.GetOrAdd(key, _ => { IStopwatch sw = CreateStopwatch(); - var assemblyRun = new TestProgressState(assembly, targetFramework, architecture, sw); + var assemblyRun = new TestProgressState(Interlocked.Increment(ref _counter), assembly, targetFramework, architecture, sw); int slotIndex = _terminalWithProgress.AddWorker(assemblyRun); assemblyRun.SlotIndex = slotIndex; @@ -176,7 +190,7 @@ private TestProgressState GetOrAddAssemblyRun(string assembly, string? targetFra }); } - internal void TestExecutionCompleted(DateTimeOffset endTime) + public void TestExecutionCompleted(DateTimeOffset endTime) { _testExecutionEndTime = endTime; _terminalWithProgress.StopShowingProgress(); @@ -375,6 +389,7 @@ internal void TestCompleted( string? targetFramework, string? architecture, string? executionId, + string testNodeUid, string displayName, TestOutcome outcome, TimeSpan duration, @@ -391,6 +406,7 @@ internal void TestCompleted( targetFramework, architecture, executionId, + testNodeUid, displayName, outcome, duration, @@ -406,6 +422,7 @@ internal void TestCompleted( string? targetFramework, string? architecture, string? executionId, + string testNodeUid, string displayName, TestOutcome outcome, TimeSpan duration, @@ -417,6 +434,11 @@ internal void TestCompleted( { TestProgressState asm = _assemblies[$"{assembly}|{targetFramework}|{architecture}|{executionId}"]; + if (_options.ShowActiveTests) + { + asm.TestNodeResultsState?.RemoveRunningTestNode(testNodeUid); + } + switch (outcome) { case TestOutcome.Error: @@ -802,7 +824,7 @@ public void ArtifactAdded(bool outOfProcess, string? assembly, string? targetFra /// /// Let the user know that cancellation was triggered. /// - internal void StartCancelling() + public void StartCancelling() { _wasCancelled = true; _terminalWithProgress.WriteToTerminal(terminal => @@ -857,7 +879,7 @@ internal void WriteWarningMessage(string assembly, string? targetFramework, stri internal void WriteErrorMessage(string assembly, string? targetFramework, string? architecture, string? executionId, Exception exception) => WriteErrorMessage(assembly, targetFramework, architecture, executionId, exception.ToString(), padding: null); - internal void WriteMessage(string text, SystemConsoleColor? color = null, int? padding = null) + public void WriteMessage(string text, SystemConsoleColor? color = null, int? padding = null) { if (color != null) { @@ -980,4 +1002,24 @@ private static TerminalColor ToTerminalColor(ConsoleColor consoleColor) 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/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/TerminalTestReporterOptions.cs b/src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/TerminalTestReporterOptions.cs index d2e84bc340..478453758d 100644 --- a/src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/TerminalTestReporterOptions.cs +++ b/src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/TerminalTestReporterOptions.cs @@ -37,6 +37,11 @@ internal sealed class TerminalTestReporterOptions /// 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. /// diff --git a/src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/TestDetailState.cs b/src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/TestDetailState.cs new file mode 100644 index 0000000000..ad00e3ee42 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Platform/OutputDevice/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/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/TestNodeResultsState.cs b/src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/TestNodeResultsState.cs new file mode 100644 index 0000000000..d2532fdcd7 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/TestNodeResultsState.cs @@ -0,0 +1,66 @@ +// 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 Microsoft.Testing.Platform.Resources; + +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 TestDetailState? GetFirstRunningTask() => _testNodeProgressStates.FirstOrDefault().Value; + + 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, PlatformResources.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, PlatformResources.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/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/TestProgressState.cs b/src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/TestProgressState.cs index d117e2df4f..7824e12bc0 100644 --- a/src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/TestProgressState.cs +++ b/src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/TestProgressState.cs @@ -7,8 +7,9 @@ namespace Microsoft.Testing.Platform.OutputDevice.Terminal; internal sealed class TestProgressState { - public TestProgressState(string assembly, string? targetFramework, string? architecture, IStopwatch stopwatch) + public TestProgressState(long id, string assembly, string? targetFramework, string? architecture, IStopwatch stopwatch) { + Id = id; Assembly = assembly; TargetFramework = targetFramework; Architecture = architecture; @@ -38,11 +39,13 @@ public TestProgressState(string assembly, string? targetFramework, string? archi public int TotalTests { get; internal set; } - public string? Detail { get; internal set; } + public TestNodeResultsState? TestNodeResultsState { get; internal set; } public int SlotIndex { get; internal set; } - public long LastUpdate { 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(); diff --git a/src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/TestProgressStateAwareTerminal.cs b/src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/TestProgressStateAwareTerminal.cs index 2f52afaa00..d369d99059 100644 --- a/src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/TestProgressStateAwareTerminal.cs +++ b/src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/TestProgressStateAwareTerminal.cs @@ -31,6 +31,10 @@ internal sealed partial class TestProgressStateAwareTerminal : IDisposable private Thread? _refresher; private long _counter; + public event EventHandler? OnProgressStartUpdate; + + public event EventHandler? OnProgressStopUpdate; + /// /// The thread proc. /// @@ -42,6 +46,7 @@ private void ThreadProc() { lock (_lock) { + OnProgressStartUpdate?.Invoke(this, EventArgs.Empty); _terminal.StartUpdate(); try { @@ -50,6 +55,7 @@ private void ThreadProc() finally { _terminal.StopUpdate(); + OnProgressStopUpdate?.Invoke(this, EventArgs.Empty); } } } @@ -175,7 +181,7 @@ internal void UpdateWorker(int slotIndex) TestProgressState? progress = _progressItems[slotIndex]; if (progress != null) { - progress.LastUpdate = _counter; + progress.Version = _counter; } } } diff --git a/src/Platform/Microsoft.Testing.Platform/OutputDevice/TerminalOutputDevice.cs b/src/Platform/Microsoft.Testing.Platform/OutputDevice/TerminalOutputDevice.cs index b5eaff5ff8..f9d521e8a6 100644 --- a/src/Platform/Microsoft.Testing.Platform/OutputDevice/TerminalOutputDevice.cs +++ b/src/Platform/Microsoft.Testing.Platform/OutputDevice/TerminalOutputDevice.cs @@ -160,6 +160,7 @@ public Task InitializeAsync() ShowPassedTests = showPassed, MinimumExpectedTests = PlatformCommandLineProvider.GetMinimumExpectedTests(_commandLineOptions), UseAnsi = !noAnsi, + ShowActiveTests = true, ShowProgress = shouldShowProgress, }); @@ -421,7 +422,13 @@ public async Task ConsumeAsync(IDataProducer dataProducer, IData value, Cancella switch (testNodeStateChanged.TestNode.Properties.SingleOrDefault()) { case InProgressTestNodeStateProperty: - // do nothing. + _terminalTestReporter.TestInProgress( + _assemblyName, + _targetFramework, + _shortArchitecture, + testNodeStateChanged.TestNode.Uid.Value, + testNodeStateChanged.TestNode.DisplayName, + executionId: null); break; case ErrorTestNodeStateProperty errorState: @@ -430,6 +437,7 @@ public async Task ConsumeAsync(IDataProducer dataProducer, IData value, Cancella _targetFramework, _shortArchitecture, executionId: null, + testNodeStateChanged.TestNode.Uid.Value, testNodeStateChanged.TestNode.DisplayName, TestOutcome.Error, duration, @@ -447,6 +455,7 @@ public async Task ConsumeAsync(IDataProducer dataProducer, IData value, Cancella _targetFramework, _shortArchitecture, executionId: null, + testNodeStateChanged.TestNode.Uid.Value, testNodeStateChanged.TestNode.DisplayName, TestOutcome.Fail, duration, @@ -464,6 +473,7 @@ public async Task ConsumeAsync(IDataProducer dataProducer, IData value, Cancella _targetFramework, _shortArchitecture, executionId: null, + testNodeStateChanged.TestNode.Uid.Value, testNodeStateChanged.TestNode.DisplayName, TestOutcome.Timeout, duration, @@ -481,6 +491,7 @@ public async Task ConsumeAsync(IDataProducer dataProducer, IData value, Cancella _targetFramework, _shortArchitecture, executionId: null, + testNodeStateChanged.TestNode.Uid.Value, testNodeStateChanged.TestNode.DisplayName, TestOutcome.Canceled, duration, @@ -498,6 +509,7 @@ public async Task ConsumeAsync(IDataProducer dataProducer, IData value, Cancella _targetFramework, _shortArchitecture, executionId: null, + testNodeStateChanged.TestNode.Uid.Value, testNodeStateChanged.TestNode.DisplayName, outcome: TestOutcome.Passed, duration: duration, @@ -515,6 +527,7 @@ public async Task ConsumeAsync(IDataProducer dataProducer, IData value, Cancella _targetFramework, _shortArchitecture, executionId: null, + testNodeStateChanged.TestNode.Uid.Value, testNodeStateChanged.TestNode.DisplayName, TestOutcome.Skipped, duration, diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/PlatformResources.resx b/src/Platform/Microsoft.Testing.Platform/Resources/PlatformResources.resx index 3ae6c7d53f..8a899ed676 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/PlatformResources.resx +++ b/src/Platform/Microsoft.Testing.Platform/Resources/PlatformResources.resx @@ -671,6 +671,12 @@ Takes one argument as string in the format <value>[h|m|s] where 'value' is The configuration file '{0}' specified with '--config-file' could not be found. + + and {0} more + + + {0} tests running + Starting test session. diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.cs.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.cs.xlf index 3bdbc0f3a3..cf6e086f71 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.cs.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.cs.xlf @@ -386,6 +386,16 @@ Při použití protokolu jsonRpc byl očekáván parametr --client-port. + + and {0} more + and {0} more + + + + {0} tests running + {0} tests running + + No serializer registered with ID '{0}' Není zaregistrovaný žádný serializátor s ID {0}. diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.de.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.de.xlf index 5d14ba2145..d72264fe27 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.de.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.de.xlf @@ -386,6 +386,16 @@ "--client-port" wird erwartet, wenn das jsonRpc-Protokoll verwendet wird. + + and {0} more + and {0} more + + + + {0} tests running + {0} tests running + + No serializer registered with ID '{0}' Es ist kein Serialisierungsmodul mit der ID "{0}" registriert diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.es.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.es.xlf index c0b9273013..41b085f814 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.es.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.es.xlf @@ -386,6 +386,16 @@ Se esperaba --client-port cuando se usa el protocolo jsonRpc. + + and {0} more + and {0} more + + + + {0} tests running + {0} tests running + + No serializer registered with ID '{0}' No hay ningún serializador registrado con el id. '{0}' diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.fr.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.fr.xlf index 0b8c6bc0b7..329c64aa1b 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.fr.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.fr.xlf @@ -386,6 +386,16 @@ Attendu --client-port attendu lorsque le protocole jsonRpc est utilisé. + + and {0} more + and {0} more + + + + {0} tests running + {0} tests running + + No serializer registered with ID '{0}' Aucun sérialiseur inscrit avec l’ID « {0} » diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.it.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.it.xlf index d3e5c55526..d8fbad5523 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.it.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.it.xlf @@ -386,6 +386,16 @@ Previsto --client-port quando viene usato il protocollo jsonRpc. + + and {0} more + and {0} more + + + + {0} tests running + {0} tests running + + No serializer registered with ID '{0}' Nessun serializzatore registrato con ID '{0}' diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ja.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ja.xlf index 04505b2af6..c75d1f90a6 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ja.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ja.xlf @@ -386,6 +386,16 @@ jsonRpc プロトコルを使用する場合、--client-port が必要です。 + + and {0} more + and {0} more + + + + {0} tests running + {0} tests running + + No serializer registered with ID '{0}' ID '{0}' で登録されたシリアライザーがありません diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ko.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ko.xlf index 8a32fd0626..dbe377653d 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ko.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ko.xlf @@ -386,6 +386,16 @@ jsonRpc 프로토콜을 사용하는 경우 --client-port가 필요합니다. + + and {0} more + and {0} more + + + + {0} tests running + {0} tests running + + No serializer registered with ID '{0}' ID '{0}'(으)로 등록된 직렬 변환기가 없습니다. diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.pl.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.pl.xlf index 6b5d42a576..7d9979dca3 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.pl.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.pl.xlf @@ -386,6 +386,16 @@ Oczekiwano parametru --client-port, gdy jest używany protokół jsonRpc. + + and {0} more + and {0} more + + + + {0} tests running + {0} tests running + + No serializer registered with ID '{0}' Nie zarejestrowano serializatora z identyfikatorem „{0}” diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.pt-BR.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.pt-BR.xlf index 396831b6e5..f4b2a515c1 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.pt-BR.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.pt-BR.xlf @@ -386,6 +386,16 @@ Esperado --client-port quando o protocolo jsonRpc é usado. + + and {0} more + and {0} more + + + + {0} tests running + {0} tests running + + No serializer registered with ID '{0}' Nenhum serializador registrado com a ID “{0}” diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ru.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ru.xlf index 6f5ec3345d..7558bc61ed 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ru.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ru.xlf @@ -386,6 +386,16 @@ Ожидается --client-port при использовании протокола jsonRpc. + + and {0} more + and {0} more + + + + {0} tests running + {0} tests running + + No serializer registered with ID '{0}' Не зарегистрирован сериализатор с ИД "{0}" diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.tr.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.tr.xlf index 25f69e948c..13990e1a13 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.tr.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.tr.xlf @@ -386,6 +386,16 @@ JsonRpc protokolü kullanıldığında --client-port bekleniyordu. + + and {0} more + and {0} more + + + + {0} tests running + {0} tests running + + No serializer registered with ID '{0}' '{0}' kimliğiyle kayıtlı seri hale getirici yok diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.zh-Hans.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.zh-Hans.xlf index 833edb5f9c..8c6f95657c 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.zh-Hans.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.zh-Hans.xlf @@ -386,6 +386,16 @@ 使用 jsonRpc 协议时应为 --client-port。 + + and {0} more + and {0} more + + + + {0} tests running + {0} tests running + + No serializer registered with ID '{0}' 没有使用 ID“{0}”注册序列化程序 diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.zh-Hant.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.zh-Hant.xlf index 218d20906d..64bddec133 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.zh-Hant.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.zh-Hant.xlf @@ -386,6 +386,16 @@ 使用 jsonRpc 通訊協定時,必須是 --client-port。 + + and {0} more + and {0} more + + + + {0} tests running + {0} tests running + + No serializer registered with ID '{0}' 沒有使用識別碼 '{0}' 註冊的序列化程式 diff --git a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/OutputDevice/Terminal/TerminalTestReporterTests.cs b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/OutputDevice/Terminal/TerminalTestReporterTests.cs index d47fe26699..d7bbe0f104 100644 --- a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/OutputDevice/Terminal/TerminalTestReporterTests.cs +++ b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/OutputDevice/Terminal/TerminalTestReporterTests.cs @@ -99,16 +99,16 @@ public void OutputFormattingIsCorrect() string standardOutput = "Hello!"; string errorOutput = "Oh no!"; - terminalReporter.TestCompleted(assembly, targetFramework, architecture, executionId: null, "PassedTest1", TestOutcome.Passed, TimeSpan.FromSeconds(10), + terminalReporter.TestCompleted(assembly, targetFramework, architecture, executionId: null, testNodeUid: "PassedTest1", "PassedTest1", TestOutcome.Passed, TimeSpan.FromSeconds(10), errorMessage: null, exception: null, expected: null, actual: null, standardOutput, errorOutput); - terminalReporter.TestCompleted(assembly, targetFramework, architecture, executionId: null, "SkippedTest1", TestOutcome.Skipped, TimeSpan.FromSeconds(10), + terminalReporter.TestCompleted(assembly, targetFramework, architecture, executionId: null, testNodeUid: "SkippedTest1", "SkippedTest1", TestOutcome.Skipped, TimeSpan.FromSeconds(10), errorMessage: null, exception: null, expected: null, actual: null, standardOutput, errorOutput); // timed out + canceled + failed should all report as failed in summary - terminalReporter.TestCompleted(assembly, targetFramework, architecture, executionId: null, "TimedoutTest1", TestOutcome.Timeout, TimeSpan.FromSeconds(10), + terminalReporter.TestCompleted(assembly, targetFramework, architecture, executionId: null, testNodeUid: "TimedoutTest1", "TimedoutTest1", TestOutcome.Timeout, TimeSpan.FromSeconds(10), errorMessage: null, exception: null, expected: null, actual: null, standardOutput, errorOutput); - terminalReporter.TestCompleted(assembly, targetFramework, architecture, executionId: null, "CanceledTest1", TestOutcome.Canceled, TimeSpan.FromSeconds(10), + terminalReporter.TestCompleted(assembly, targetFramework, architecture, executionId: null, testNodeUid: "CanceledTest1", "CanceledTest1", TestOutcome.Canceled, TimeSpan.FromSeconds(10), errorMessage: null, exception: null, expected: null, actual: null, standardOutput, errorOutput); - terminalReporter.TestCompleted(assembly, targetFramework, architecture, executionId: null, "FailedTest1", TestOutcome.Fail, TimeSpan.FromSeconds(10), + terminalReporter.TestCompleted(assembly, targetFramework, architecture, executionId: null, testNodeUid: "FailedTest1", "FailedTest1", TestOutcome.Fail, TimeSpan.FromSeconds(10), errorMessage: "Tests failed", exception: new StackTraceException(@$" at FailingTest() in {folder}codefile.cs:line 10"), expected: "ABC", actual: "DEF", standardOutput, errorOutput); terminalReporter.ArtifactAdded(outOfProcess: true, assembly, targetFramework, architecture, executionId: null, testName: null, @$"{folder}artifact1.txt"); terminalReporter.ArtifactAdded(outOfProcess: false, assembly, targetFramework, architecture, executionId: null, testName: null, @$"{folder}artifact2.txt"); @@ -167,12 +167,142 @@ Oh no! Assert.AreEqual(expected, ShowEscape(output)); } + public void OutputProgressFrameIsCorrect() + { + var stringBuilderConsole = new StringBuilderConsole(); + var stopwatchFactory = new StopwatchFactory(); + var terminalReporter = new TerminalTestReporter(stringBuilderConsole, new TerminalTestReporterOptions + { + ShowPassedTests = () => true, + UseAnsi = true, + ForceAnsi = true, + + ShowActiveTests = true, + ShowAssembly = false, + ShowAssemblyStartAndComplete = false, + ShowProgress = () => true, + }) + { + CreateStopwatch = stopwatchFactory.CreateStopwatch, + }; + + var startHandle = new AutoResetEvent(initialState: false); + var stopHandle = new AutoResetEvent(initialState: false); + + // Note: Ensure that we disable the timer updates, so that we can have deterministic output. + terminalReporter.OnProgressStartUpdate += (sender, args) => startHandle.WaitOne(); + terminalReporter.OnProgressStopUpdate += (sender, args) => stopHandle.Set(); + + DateTimeOffset startTime = DateTimeOffset.MinValue; + DateTimeOffset endTime = DateTimeOffset.MaxValue; + terminalReporter.TestExecutionStarted(startTime, 1, isDiscovery: false); + + string targetFramework = "net8.0"; + string architecture = "x64"; + string assembly = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? @"C:\work\assembly.dll" : "/mnt/work/assembly.dll"; + string folder = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? @"C:\work\" : "/mnt/work/"; + string folderNoSlash = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? @"C:\work" : "/mnt/work"; + string folderLink = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? @"C:/work/" : "mnt/work/"; + string folderLinkNoSlash = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? @"C:/work" : "mnt/work"; + + terminalReporter.AssemblyRunStarted(assembly, targetFramework, architecture, executionId: null); + string standardOutput = "Hello!"; + string errorOutput = "Oh no!"; + + // Note: Add 1ms to make the order of the progress frame deterministic. + // Otherwise all tests that run for 1m31s could show in any order. + terminalReporter.TestInProgress(assembly, targetFramework, architecture, testNodeUid: "PassedTest1", displayName: "PassedTest1", executionId: null); + stopwatchFactory.AddTime(TimeSpan.FromMilliseconds(1)); + terminalReporter.TestInProgress(assembly, targetFramework, architecture, testNodeUid: "SkippedTest1", displayName: "SkippedTest1", executionId: null); + stopwatchFactory.AddTime(TimeSpan.FromMilliseconds(1)); + terminalReporter.TestInProgress(assembly, targetFramework, architecture, testNodeUid: "InProgressTest1", displayName: "InProgressTest1", executionId: null); + stopwatchFactory.AddTime(TimeSpan.FromMinutes(1)); + terminalReporter.TestInProgress(assembly, targetFramework, architecture, testNodeUid: "InProgressTest2", displayName: "InProgressTest2", executionId: null); + stopwatchFactory.AddTime(TimeSpan.FromSeconds(30)); + terminalReporter.TestInProgress(assembly, targetFramework, architecture, testNodeUid: "InProgressTest3", displayName: "InProgressTest3", executionId: null); + stopwatchFactory.AddTime(TimeSpan.FromSeconds(1)); + + terminalReporter.TestCompleted(assembly, targetFramework, architecture, executionId: null, testNodeUid: "PassedTest1", "PassedTest1", TestOutcome.Passed, TimeSpan.FromSeconds(10), + errorMessage: null, exception: null, expected: null, actual: null, standardOutput, errorOutput); + terminalReporter.TestCompleted(assembly, targetFramework, architecture, executionId: null, testNodeUid: "SkippedTest1", "SkippedTest1", TestOutcome.Skipped, TimeSpan.FromSeconds(10), + errorMessage: null, exception: null, expected: null, actual: null, standardOutput, errorOutput); + + string output = stringBuilderConsole.Output; + startHandle.Set(); + stopHandle.WaitOne(); + + // Note: On MacOS the busy indicator is not rendered. + bool useBusyIndicator = !RuntimeInformation.IsOSPlatform(OSPlatform.OSX); + string busyIndicatorString = useBusyIndicator ? "␛]9;4;3;␛\\" : string.Empty; + + // Note: The progress is drawn after each completed event. + string expected = $""" + {busyIndicatorString}␛[?25l␛[92mpassed␛[m PassedTest1␛[90m ␛[90m(10s 000ms)␛[m + ␛[90m Standard output + Hello! + Error output + Oh no! + ␛[m + [␛[92m✓1␛[m/␛[91mx0␛[m/␛[93m↓0␛[m] assembly.dll (net8.0|x64)␛[2147483640G(1m 31s) + SkippedTest1␛[2147483640G(1m 31s) + InProgressTest1␛[2147483640G(1m 31s) + InProgressTest2␛[2147483643G(31s) + InProgressTest3␛[2147483644G(1s) + ␛[7F + ␛[J␛[93mskipped␛[m SkippedTest1␛[90m ␛[90m(10s 000ms)␛[m + ␛[90m Standard output + Hello! + Error output + Oh no! + ␛[m + [␛[92m✓1␛[m/␛[91mx0␛[m/␛[93m↓1␛[m] assembly.dll (net8.0|x64)␛[2147483640G(1m 31s) + InProgressTest1␛[2147483640G(1m 31s) + InProgressTest2␛[2147483643G(31s) + InProgressTest3␛[2147483644G(1s) + + """; + + Assert.AreEqual(expected, ShowEscape(output)); + } + private static string? ShowEscape(string? text) { string visibleEsc = "\x241b"; return text?.Replace(AnsiCodes.Esc, visibleEsc); } + internal sealed class StopwatchFactory + { + private TimeSpan _currentTime = TimeSpan.Zero; + + public void AddTime(TimeSpan time) => _currentTime += time; + + public IStopwatch CreateStopwatch() => new MockStopwatch(this, _currentTime); + + internal sealed class MockStopwatch : IStopwatch + { + private readonly StopwatchFactory _factory; + + public MockStopwatch(StopwatchFactory factory, TimeSpan startTime) + { + _factory = factory; + StartTime = startTime; + } + + private TimeSpan StartTime { get; } + + public TimeSpan Elapsed => _factory._currentTime - StartTime; + + public void Start() + { + } + + public void Stop() + { + } + } + } + internal class StringBuilderConsole : IConsole { private readonly StringBuilder _output = new(); @@ -181,7 +311,7 @@ internal class StringBuilderConsole : IConsole public int BufferWidth => int.MinValue; - public bool IsOutputRedirected => throw new NotImplementedException(); + public bool IsOutputRedirected => false; public string Output => _output.ToString();