Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/Cli/dotnet/Commands/CliCommandStrings.resx
Original file line number Diff line number Diff line change
Expand Up @@ -2697,6 +2697,10 @@ Proceed?</value>
<data name="DurationColon" xml:space="preserve">
<value>duration:</value>
</data>
<data name="HandshakeFailuresHeader" xml:space="preserve">
<value>Handshake failures:</value>
<comment>Section header printed at the end of a 'dotnet test' run when one or more test hosts failed to hand-shake with the SDK. Followed by per-assembly exit code, stdout and stderr.</comment>
</data>
<data name="WorkloadSetHasMissingManifests" xml:space="preserve">
<value>Workload set version {0} has missing manifests likely removed by package management. Run "dotnet workload repair" to fix this.</value>
<comment>{0} is the workload set version. {Locked="dotnet workload repair"}</comment>
Expand Down
78 changes: 68 additions & 10 deletions src/Cli/dotnet/Commands/Test/MTP/Terminal/TerminalTestReporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ internal sealed partial class TerminalTestReporter : IDisposable

private int _handshakeFailuresCount;

private readonly object _handshakeFailuresLock = new();
private readonly List<HandshakeFailureRecord> _handshakeFailures = new();

private readonly uint? _originalConsoleMode;
private bool _isDiscovery;
private bool _isHelp;
Expand Down Expand Up @@ -166,6 +169,11 @@ public void TestExecutionCompleted(DateTimeOffset endTime, int? exitCode)

NativeMethods.RestoreConsoleMode(_originalConsoleMode);
_assemblies.Clear();
lock (_handshakeFailuresLock)
{
_handshakeFailures.Clear();
}
_handshakeFailuresCount = 0;
_buildErrorsCount = 0;
_testExecutionStartTime = null;
_testExecutionEndTime = null;
Expand Down Expand Up @@ -226,14 +234,17 @@ private void AppendTestRunSummary(ITerminal terminal, int? exitCode)
{
terminal.Append(string.Format(CultureInfo.CurrentCulture, CliCommandStrings.MinimumExpectedTestsPolicyViolation, totalTests, _options.MinimumExpectedTests));
}
else if (allTestsWereSkipped)
{
terminal.Append(CliCommandStrings.ZeroTestsRan);
}
else if (anyTestFailed || anyAssemblyFailed)
{
// Handshake/assembly failures take precedence over "Zero tests ran": when an assembly
// failed to hand-shake we want the headline to reflect that the run failed, not that
// no tests ran (which would imply a benign empty run).
terminal.Append(string.Format(CultureInfo.CurrentCulture, "{0}!", CliCommandStrings.Failed));
}
else if (allTestsWereSkipped)
{
terminal.Append(CliCommandStrings.ZeroTestsRan);
}
else
{
terminal.Append(string.Format(CultureInfo.CurrentCulture, "{0}!", CliCommandStrings.Passed));
Expand Down Expand Up @@ -347,6 +358,39 @@ private void AppendTestRunSummary(ITerminal terminal, int? exitCode)
terminal.AppendLine();

AppendExitCodeAndUrl(terminal, exitCode, isRun: true);

AppendHandshakeFailureRecap(terminal);
}

private void AppendHandshakeFailureRecap(ITerminal terminal)
{
// Re-print handshake failures captured during the run so that — even when there is a lot of
// diagnostic output before the summary — the user sees the actionable failure context
// (assembly, exit code, stdout, stderr) at the end of the run rather than having to scroll
// back. See https://github.com/dotnet/sdk/issues/51608.
HandshakeFailureRecord[] failures;
lock (_handshakeFailuresLock)
{
if (_handshakeFailures.Count == 0)
{
return;
}

failures = _handshakeFailures.ToArray();
}

terminal.AppendLine();
terminal.SetColor(TerminalColor.DarkRed);
terminal.AppendLine(CliCommandStrings.HandshakeFailuresHeader);
terminal.ResetColor();

foreach (HandshakeFailureRecord failure in failures)
{
terminal.Append(SingleIndentation);
AppendAssemblyLinkTargetFrameworkAndArchitecture(terminal, failure.AssemblyPath, failure.TargetFramework, architecture: null);
terminal.AppendLine();
AppendExecutableSummary(terminal, failure.ExitCode, failure.OutputData, failure.ErrorData);
}
}

private static void AppendExitCodeAndUrl(ITerminal terminal, int? exitCode, bool isRun)
Expand Down Expand Up @@ -731,12 +775,14 @@ internal void AssemblyRunCompleted(string executionId,
// In single process run, like with testing platform .exe we report these via messages, and run exit.
int exitCode, string? outputData, string? errorData)
{
// Defense in depth: AssemblyRunCompleted is normally only invoked for an executionId that
// AssemblyRunStarted already registered in _assemblies (via GetOrAddAssemblyRun, gated on the
// TestHost handshake by TestApplicationHandler). If a future regression or lifecycle race
// (e.g., _assemblies.Clear() running before this completes) hands us an unknown id, surface
// a handshake failure instead of throwing a KeyNotFoundException that buries the real exit
// cause.
// Defense in depth: under the current TestApplicationHandler routing this branch is
// unreachable in normal operation. OnTestProcessExited only calls AssemblyRunCompleted
// when the TestHost handshake was received (which in turn caused AssemblyRunStarted to
// register the executionId via GetOrAddAssemblyRun); controller-only handshakes go to
// HandshakeFailure directly. The fallback below exists only to keep stray completions
// (e.g. one that arrives after TestExecutionCompleted's _assemblies.Clear()) or a future
// routing regression from surfacing as a KeyNotFoundException that would bury the real
// exit cause.
if (!_assemblies.TryGetValue(executionId, out TestProgressState? assemblyRun))
{
HandshakeFailure(assemblyPath: string.Empty, targetFramework: null, exitCode, outputData ?? string.Empty, errorData ?? string.Empty);
Expand Down Expand Up @@ -777,6 +823,11 @@ internal void HandshakeFailure(string assemblyPath, string? targetFramework, int
}

Interlocked.Increment(ref _handshakeFailuresCount);
lock (_handshakeFailuresLock)
{
_handshakeFailures.Add(new HandshakeFailureRecord(assemblyPath, targetFramework, exitCode, outputData, errorData));
}

_terminalWithProgress.WriteToTerminal(terminal =>
{
terminal.ResetColor();
Expand Down Expand Up @@ -976,4 +1027,11 @@ public void TestInProgress(

_terminalWithProgress.UpdateWorker(asm.SlotIndex);
}

private readonly record struct HandshakeFailureRecord(
string AssemblyPath,
string? TargetFramework,
int ExitCode,
string OutputData,
string ErrorData);
}
5 changes: 5 additions & 0 deletions src/Cli/dotnet/Commands/xlf/CliCommandStrings.cs.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Cli/dotnet/Commands/xlf/CliCommandStrings.de.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Cli/dotnet/Commands/xlf/CliCommandStrings.es.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Cli/dotnet/Commands/xlf/CliCommandStrings.fr.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Cli/dotnet/Commands/xlf/CliCommandStrings.it.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Cli/dotnet/Commands/xlf/CliCommandStrings.ja.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Cli/dotnet/Commands/xlf/CliCommandStrings.ko.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Cli/dotnet/Commands/xlf/CliCommandStrings.pl.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Cli/dotnet/Commands/xlf/CliCommandStrings.pt-BR.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Cli/dotnet/Commands/xlf/CliCommandStrings.ru.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Cli/dotnet/Commands/xlf/CliCommandStrings.tr.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Cli/dotnet/Commands/xlf/CliCommandStrings.zh-Hans.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Cli/dotnet/Commands/xlf/CliCommandStrings.zh-Hant.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

56 changes: 56 additions & 0 deletions test/dotnet.Tests/CommandTests/Test/CapturingConsole.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Text;
using Microsoft.DotNet.Cli.Commands.Test.Terminal;

namespace dotnet.Tests.CommandTests.Test;

internal sealed class CapturingConsole : IConsole
{
private readonly StringBuilder _output = new();
private ConsoleColor _foreground = ConsoleColor.Gray;
private ConsoleColor _background = ConsoleColor.Black;

#pragma warning disable CS0067 // Event is never used; required by IConsole contract.
public event ConsoleCancelEventHandler? CancelKeyPress;
#pragma warning restore CS0067

public int BufferHeight => 30;

public int BufferWidth => 120;

public bool IsOutputRedirected => true;

public string GetOutput() => _output.ToString();

public void SetForegroundColor(ConsoleColor color) => _foreground = color;

public void SetBackgroundColor(ConsoleColor color) => _background = color;

public ConsoleColor GetForegroundColor() => _foreground;

public ConsoleColor GetBackgroundColor() => _background;

public void WriteLine() => _output.AppendLine();

public void WriteLine(string? value) => _output.AppendLine(value);

public void WriteLine(object? value) => _output.AppendLine(value?.ToString());

public void WriteLine(string format, object? arg0) => _output.AppendLine(string.Format(format, arg0));

public void WriteLine(string format, object? arg0, object? arg1) => _output.AppendLine(string.Format(format, arg0, arg1));

public void WriteLine(string format, object? arg0, object? arg1, object? arg2) => _output.AppendLine(string.Format(format, arg0, arg1, arg2));

public void WriteLine(string format, object?[]? args) => _output.AppendLine(string.Format(format, args ?? Array.Empty<object?>()));

public void Write(string format, object?[]? args) => _output.Append(string.Format(format, args ?? Array.Empty<object?>()));

public void Write(string? value) => _output.Append(value);

public void Write(char value) => _output.Append(value);

public void Clear() => _output.Clear();
}
Loading
Loading