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 9d9e4e4377..c963c785e3 100644 --- a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/OutputDevice/Terminal/TerminalTestReporterTests.cs +++ b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/OutputDevice/Terminal/TerminalTestReporterTests.cs @@ -1036,6 +1036,87 @@ public void AnsiTerminal_UsesWindowWidthNotBufferWidth() Assert.AreEqual(120, terminal.Width); } + /// + /// Locks in the PR #8348 fix for issue #6753: when the progress state has not changed between + /// two refresh ticks (same ProgressId + ProgressVersion), the renderer must only + /// rewrite the duration cell instead of erasing and re-emitting the whole progress line. + /// + /// Before the fix the optimization was guarded by a && false leftover, which made every + /// 500 ms tick fall through to the full re-render branch (CSI K + counters + assembly name). + /// This test fails if that branch is ever disabled again. + /// + [TestMethod] + public void AnsiTerminal_ProgressFrame_OnlyUpdatesDuration_WhenProgressVersionUnchanged() + { + string targetFramework = "net8.0"; + string architecture = "x64"; + string assembly = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? @"C:\work\assembly.dll" : "/mnt/work/assembly.dll"; + + var stringBuilderConsole = new StringBuilderConsole(); + var stopwatchFactory = new StopwatchFactory(); + var terminalReporter = new TerminalTestReporter(assembly, targetFramework, architecture, stringBuilderConsole, new CTRLPlusCCancellationTokenSource(), new TerminalTestReporterOptions + { + ShowPassedTests = () => true, + AnsiMode = AnsiMode.ForceAnsi, + + // Intentionally do NOT enable ShowActiveTests: the optimization is per-line and we keep + // the rendered frame to a single line (the assembly progress) to make assertions simple. + ShowActiveTests = false, + ShowProgress = () => true, + }) + { + CreateStopwatch = stopwatchFactory.CreateStopwatch, + }; + + // Gate the refresher thread so renders happen one at a time, on our cue, with deterministic + // duration values. Without this, the 500 ms timer would race with our assertions. + var renderGate = new AutoResetEvent(initialState: false); + var renderDone = new AutoResetEvent(initialState: false); + terminalReporter.OnProgressStartUpdate += (sender, args) => renderGate.WaitOne(); + terminalReporter.OnProgressStopUpdate += (sender, args) => renderDone.Set(); + + terminalReporter.TestExecutionStarted(DateTimeOffset.MinValue, 1, isDiscovery: false); + terminalReporter.AssemblyRunStarted(); + + // Pick a starting elapsed value whose rendered form ("1s") has the same length as the value + // we will use for the second tick ("2s"). The duration-only path only fires when the rendered + // duration string has the same length as the one rendered in the previous frame. + stopwatchFactory.AddTime(TimeSpan.FromSeconds(1)); + + // First tick: nothing was rendered yet, so this is the full frame. + int beforeFirstRender = stringBuilderConsole.Output.Length; + renderGate.Set(); + renderDone.WaitOne(); + string firstRender = stringBuilderConsole.Output[beforeFirstRender..]; + + // Sanity: the first render is the full frame (counters + assembly name + duration). + Assert.Contains("assembly.dll", firstRender); + Assert.Contains("(1s)", firstRender); + + // Advance the clock by 1 second without touching any progress state. The worker version is + // unchanged, so the next render should take the "same Id + Version → duration-only" path. + stopwatchFactory.AddTime(TimeSpan.FromSeconds(1)); + + int beforeSecondRender = stringBuilderConsole.Output.Length; + renderGate.Set(); + renderDone.WaitOne(); + string secondRender = stringBuilderConsole.Output[beforeSecondRender..]; + + // The duration-only path writes only the new duration with cursor positioning; it must not + // re-emit the counters, the assembly name, or a CSI K erase-in-line. + Assert.Contains("(2s)", secondRender); + Assert.DoesNotContain("assembly.dll", secondRender); + Assert.DoesNotContain("(1s)", secondRender); + Assert.DoesNotContain($"{AnsiCodes.CSI}{AnsiCodes.EraseInLine}", secondRender); + Assert.DoesNotContain("✓", secondRender); + Assert.Contains(AnsiCodes.SetCursorHorizontal(250), secondRender); + + // Note: we deliberately do not stop the reporter here. The refresher thread is a background + // thread that is currently blocked in OnProgressStartUpdate; calling StopShowingProgress + // (which Joins the thread) would deadlock. The existing tests in this file follow the same + // pattern - the thread dies with the test process. + } + /// /// Reproduces the bug from issue #7240: when Console.BufferWidth > Console.WindowWidth, /// the ANSI cursor positioning places timings off-screen because it was using BufferWidth