diff --git a/src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/AnsiTerminal.cs b/src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/AnsiTerminal.cs index a3e82d9c4d..14dbd882b6 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) { @@ -275,27 +275,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 1ddd457c42..5e86b67517 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(progress.Stopwatch.Elapsed); - string durationString = HumanReadableDurationFormatter.Render(p.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,55 +163,132 @@ 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(); + } + + // 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); } terminal.AppendLine(); int i = 0; - for (; i < ProgressCount; i++) + RenderedLines = new List(progress.Length * 2); + var progresses = new List(progress.Length); + + foreach (TestProgressState? progressItem in progress) { - // 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 (progressItem == null) + { + continue; + } + + progresses.Add(progressItem); + if (progressItem.Detail != null) { - if (previousFrame._progressItems[i].TestProgressState.LastUpdate != _progressItems[i].TestProgressState.LastUpdate) + progresses.Add(progressItem.Detail); + } + } + + foreach (object item in progresses) + { + if (previousFrame.RenderedLines != null && previousFrame.RenderedLines.Count > i) + { + 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 && previouslyRenderedLine.ProgressVersion == progressItem.Version) { - 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 @@ -223,12 +297,29 @@ 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; + public void Clear() => RenderedLines?.Clear(); + + internal 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/NonAnsiTerminal.cs b/src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/NonAnsiTerminal.cs index 2af7044ad9..1ae289873c 100644 --- a/src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/NonAnsiTerminal.cs +++ b/src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/NonAnsiTerminal.cs @@ -179,7 +179,7 @@ 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; + string? detail = !RoslynString.IsNullOrWhiteSpace(p.Detail?.Text) ? $"- {p.Detail.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 3d5e45c7ae..977c1d0d6e 100644 --- a/src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/TerminalTestReporter.cs +++ b/src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/TerminalTestReporter.cs @@ -101,10 +101,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; @@ -163,7 +165,7 @@ private TestProgressState GetOrAddAssemblyRun(string assembly, string? targetFra } IStopwatch sw = CreateStopwatch(); - var assemblyRun = new TestProgressState(assembly, targetFramework, architecture, sw); + var assemblyRun = new TestProgressState(_counter++, assembly, targetFramework, architecture, sw); int slotIndex = _terminalWithProgress.AddWorker(assemblyRun); assemblyRun.SlotIndex = slotIndex; @@ -172,7 +174,7 @@ private TestProgressState GetOrAddAssemblyRun(string assembly, string? targetFra return assemblyRun; } - internal void TestExecutionCompleted(DateTimeOffset endTime) + public void TestExecutionCompleted(DateTimeOffset endTime) { _testExecutionEndTime = endTime; _terminalWithProgress.StopShowingProgress(); @@ -630,7 +632,7 @@ private static void AppendIndentedLine(ITerminal terminal, string? message, stri } } - internal void AssemblyRunCompleted(string assembly, string? targetFramework, string? architecture) + public void AssemblyRunCompleted(string assembly, string? targetFramework, string? architecture) { TestProgressState assemblyRun = GetOrAddAssemblyRun(assembly, targetFramework, architecture); assemblyRun.Stopwatch.Stop(); @@ -681,7 +683,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 => @@ -692,7 +694,7 @@ internal void StartCancelling() }); } - internal void WriteErrorMessage(string assembly, string? targetFramework, string? architecture, string text, int? padding) + public void WriteErrorMessage(string assembly, string? targetFramework, string? architecture, string text, int? padding) { TestProgressState asm = GetOrAddAssemblyRun(assembly, targetFramework, architecture); asm.AddError(text); @@ -713,7 +715,7 @@ internal void WriteErrorMessage(string assembly, string? targetFramework, string }); } - internal void WriteWarningMessage(string assembly, string? targetFramework, string? architecture, string text, int? padding) + public void WriteWarningMessage(string assembly, string? targetFramework, string? architecture, string text, int? padding) { TestProgressState asm = GetOrAddAssemblyRun(assembly, targetFramework, architecture); asm.AddWarning(text); @@ -733,10 +735,10 @@ internal void WriteWarningMessage(string assembly, string? targetFramework, stri }); } - internal void WriteErrorMessage(string assembly, string? targetFramework, string? architecture, Exception exception) + public void WriteErrorMessage(string assembly, string? targetFramework, string? architecture, Exception exception) => WriteErrorMessage(assembly, targetFramework, architecture, 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) { @@ -792,4 +794,16 @@ private static TerminalColor ToTerminalColor(ConsoleColor consoleColor) ConsoleColor.White => TerminalColor.White, _ => TerminalColor.Default, }; + + public void TestInProgress( + string assembly, + string? targetFramework, + string? architecture, + string displayName) + { + TestProgressState asm = _assemblies[$"{assembly}|{targetFramework}|{architecture}"]; + + asm.Detail = new(_counter++, CreateStopwatch(), displayName); + _terminalWithProgress.UpdateWorker(asm.SlotIndex); + } } 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..5e369ba82e --- /dev/null +++ b/src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/TestDetailState.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.Testing.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 + { + Version++; + _text = value; + } + } +} diff --git a/src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/TestProgressState.cs b/src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/TestProgressState.cs index 6bffb4cc42..75a317c6ef 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; @@ -42,11 +43,13 @@ public TestProgressState(string assembly, string? targetFramework, string? archi public int CanceledTests { get; internal set; } - public string? Detail { get; internal set; } + public TestDetailState? Detail { 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; } internal void AddError(string text) => Messages.Add(new ErrorMessage(text)); diff --git a/src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/TestProgressStateAwareTerminal.cs b/src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/TestProgressStateAwareTerminal.cs index 41c833833b..1d4d252dfa 100644 --- a/src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/TestProgressStateAwareTerminal.cs +++ b/src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/TestProgressStateAwareTerminal.cs @@ -161,7 +161,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 78fee55f33..19727a6cbb 100644 --- a/src/Platform/Microsoft.Testing.Platform/OutputDevice/TerminalOutputDevice.cs +++ b/src/Platform/Microsoft.Testing.Platform/OutputDevice/TerminalOutputDevice.cs @@ -392,7 +392,11 @@ 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.DisplayName); break; case ErrorTestNodeStateProperty errorState: