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
Original file line number Diff line number Diff line change
Expand Up @@ -298,16 +298,16 @@ public async Task<int> OrchestrateTestHostExecutionAsync(CancellationToken cance
catch (OperationCanceledException) when (processExitedCancellationToken.IsCancellationRequested)
{
await outputDevice.DisplayAsync(this, new ErrorMessageOutputDeviceData(string.Format(CultureInfo.InvariantCulture, ExtensionResources.TestHostProcessExitedBeforeRetryCouldConnect, testHostProcess.ExitCode)), cancellationToken).ConfigureAwait(false);
return ExitCodes.GenericFailure;
return (int)ExitCodes.GenericFailure;
}
}

await testHostProcess.WaitForExitAsync().ConfigureAwait(false);

exitCodes.Add(testHostProcess.ExitCode);
if (testHostProcess.ExitCode != ExitCodes.Success)
if (testHostProcess.ExitCode != (int)ExitCodes.Success)
{
if (testHostProcess.ExitCode != ExitCodes.AtLeastOneTestFailed)
if (testHostProcess.ExitCode != (int)ExitCodes.AtLeastOneTestFailed)
{
await outputDevice.DisplayAsync(this, new WarningMessageOutputDeviceData(string.Format(CultureInfo.InvariantCulture, ExtensionResources.TestSuiteFailedWithWrongExitCode, testHostProcess.ExitCode)), cancellationToken).ConfigureAwait(false);
retryInterrupted = true;
Expand Down Expand Up @@ -370,7 +370,7 @@ public async Task<int> OrchestrateTestHostExecutionAsync(CancellationToken cance

if (!thresholdPolicyKickedIn && !retryInterrupted)
{
if (exitCodes[^1] != ExitCodes.Success)
if (exitCodes[^1] != (int)ExitCodes.Success)
{
await outputDevice.DisplayAsync(this, new ErrorMessageOutputDeviceData(string.Format(CultureInfo.InvariantCulture, ExtensionResources.TestSuiteFailedInAllAttempts, userMaxRetryCount + 1)), cancellationToken).ConfigureAwait(false);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,12 +73,12 @@ await _task.WhenAll(
if (AreMatchingTrxFiles(baseLineResults, comparedResults, outputBuilder))
{
await _outputDisplay.DisplayAsync(this, new TextOutputDeviceData(outputBuilder.ToString()), cancellationToken).ConfigureAwait(false);
return ExitCodes.Success;
return (int)ExitCodes.Success;
}
else
{
await _outputDisplay.DisplayAsync(this, new TextOutputDeviceData(outputBuilder.ToString()), cancellationToken).ConfigureAwait(false);
return ExitCodes.GenericFailure;
return (int)ExitCodes.GenericFailure;
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ public TrxReportEngine(
AddTestLists(testRun);

bool hasFailedTests = summaryCounts.Failed > 0 || summaryCounts.Timedout > 0;
string trxOutcome = isTestHostCrashed || _exitCode != ExitCodes.Success || hasFailedTests ? "Failed" : "Completed";
string trxOutcome = isTestHostCrashed || _exitCode != (int)ExitCodes.Success || hasFailedTests ? "Failed" : "Completed";

AddResultSummary(testRun, trxOutcome, runDeploymentRoot, testHostCrashInfo, _exitCode, summaryCounts, isTestHostCrashed);

Expand Down Expand Up @@ -333,7 +333,7 @@ private void AddResultSummary(XElement testRun, string resultSummaryOutcome, str
runInfo.Add(text);
runInfos.Add(runInfo);
}
else if (exitCode != ExitCodes.Success)
else if (exitCode != (int)ExitCodes.Success)
{
var runInfos = new XElement(NamespaceUri + "RunInfos");
resultSummary.Add(runInfos);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -433,14 +433,14 @@ protected override void LogToolCommand(string message)
protected override bool HandleTaskExecutionErrors()
{
// This is an unexpected situation we simply print to the console the output and return false.
if (string.IsNullOrEmpty(_outputFileName) && ExitCode != ExitCodes.InvalidCommandLine)
if (string.IsNullOrEmpty(_outputFileName) && ExitCode != (int)ExitCodes.InvalidCommandLine)
{
Log.LogError(null, "run failed", null, TargetPath.ItemSpec.Trim(), 0, 0, 0, 0, Resources.MSBuildResources.TestFailedNoDetail, _output);
}
else
{
// If the output file name is null and the exit code is invalid command line we create a default one.
if (_outputFileName is null && ExitCode == ExitCodes.InvalidCommandLine)
if (_outputFileName is null && ExitCode == (int)ExitCodes.InvalidCommandLine)
{
_outputFileName = Path.Combine(Path.GetDirectoryName(TargetPath.ItemSpec.Trim())!, "TestResults");
_fileSystem.CreateDirectory(_outputFileName);
Expand Down
34 changes: 15 additions & 19 deletions src/Platform/Microsoft.Testing.Platform/Helpers/ExitCodes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,25 +9,21 @@ namespace Microsoft.Testing.Platform.Helpers;
/// We use positive exit codes for failure because POSIX/BASH exit codes are unsigned 8-bit integers.
/// On POSIX systems the standard exit code is 0 for success and any number from 1 to 255 for anything else.
/// </summary>
// TODO: Consider changing this to an enum, and rename to 'ExitCode' to follow enum naming convention.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renaming wasn't addressed here.

// Being an enum makes it easier to do 'Enum.IsDefined' checks to validate if an exit code is a known MTP exit code.
// Note: Changing this to enum would be binary breaking for extensions built against MTP <= 2.1 that still consume this via IVT
// (those extensions reference the class directly from the MTP assembly, not via source embedding).
[Embedded]
internal static class ExitCodes
internal enum ExitCodes
{
public const int Success = 0;
public const int GenericFailure = 1;
public const int AtLeastOneTestFailed = 2;
public const int TestSessionAborted = 3;
public const int InvalidPlatformSetup = 4;
public const int InvalidCommandLine = 5;
// public const int FeatureNotImplemented = 6;
public const int TestHostProcessExitedNonGracefully = 7;
public const int ZeroTests = 8;
public const int MinimumExpectedTestsPolicyViolation = 9;
public const int TestAdapterTestSessionFailure = 10;
public const int DependentProcessExited = 11;
public const int IncompatibleProtocolVersion = 12;
public const int TestExecutionStoppedForMaxFailedTests = 13;
Success = 0,
GenericFailure = 1,
AtLeastOneTestFailed = 2,
TestSessionAborted = 3,
InvalidPlatformSetup = 4,
InvalidCommandLine = 5,
// FeatureNotImplemented = 6,
TestHostProcessExitedNonGracefully = 7,
ZeroTests = 8,
MinimumExpectedTestsPolicyViolation = 9,
TestAdapterTestSessionFailure = 10,
DependentProcessExited = 11,
IncompatibleProtocolVersion = 12,
TestExecutionStoppedForMaxFailedTests = 13,
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,11 @@ private void SubscribeToParentProcess()
{
// If we fail the process is already gone, so we can just exit.
// The first check is already done inside the command line parser.
_environment.Exit(ExitCodes.DependentProcessExited);
_environment.Exit((int)ExitCodes.DependentProcessExited);
}
}

private void ParentProcess_Exited(object? sender, EventArgs e) => _environment.Exit(ExitCodes.DependentProcessExited);
private void ParentProcess_Exited(object? sender, EventArgs e) => _environment.Exit((int)ExitCodes.DependentProcessExited);

public void Dispose() => _parentProcess?.Dispose();
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ public async Task<int> RunAsync()
{
CancellationToken testApplicationCancellationToken = ServiceProvider.GetTestApplicationCancellationTokenSource().CancellationToken;

int exitCode = ExitCodes.GenericFailure;
int exitCode = (int)ExitCodes.GenericFailure;
IPlatformOpenTelemetryService? platformOTelService = null;
IPlatformActivity? activity = null;
try
Expand All @@ -45,7 +45,7 @@ public async Task<int> RunAsync()

if (testApplicationCancellationToken.IsCancellationRequested)
{
exitCode = ExitCodes.TestSessionAborted;
exitCode = (int)ExitCodes.TestSessionAborted;
}

return exitCode;
Expand All @@ -59,7 +59,7 @@ public async Task<int> RunAsync()

exitCode = isValidProtocol
? await RunTestAppAsync(platformOTelService, testApplicationCancellationToken).ConfigureAwait(false)
: ExitCodes.IncompatibleProtocolVersion;
: (int)ExitCodes.IncompatibleProtocolVersion;
}
finally
{
Expand Down Expand Up @@ -90,7 +90,7 @@ public async Task<int> RunAsync()

if (testApplicationCancellationToken.IsCancellationRequested)
{
exitCode = ExitCodes.TestSessionAborted;
exitCode = (int)ExitCodes.TestSessionAborted;
}

return exitCode;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ await ExecuteRequestAsync(
{
requestExecuteStop ??= _clock.UtcNow;

exitCode = ExitCodes.TestSessionAborted;
exitCode = (int)ExitCodes.TestSessionAborted;
await _logger.LogInformationAsync("Test session canceled.").ConfigureAwait(false);
}
finally
Expand All @@ -128,7 +128,7 @@ await ExecuteRequestAsync(
{ TelemetryProperties.RequestProperties.AdapterLoadStop, adapterLoadStop },
{ TelemetryProperties.RequestProperties.RequestExecuteStart, requestExecuteStart },
{ TelemetryProperties.RequestProperties.RequestExecuteStop, requestExecuteStop },
{ TelemetryProperties.HostProperties.ExitCodePropertyName, cancellationToken.IsCancellationRequested ? ExitCodes.TestSessionAborted : exitCode.ToString(CultureInfo.InvariantCulture) },
{ TelemetryProperties.HostProperties.ExitCodePropertyName, cancellationToken.IsCancellationRequested ? (int)ExitCodes.TestSessionAborted : exitCode.ToString(CultureInfo.InvariantCulture) },
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Telemetry metric ExitCodePropertyName is currently emitted as an int when cancellation is requested, but as a string otherwise. This creates inconsistent telemetry schema (and differs from other hosts which always emit exitCode.ToString(...)). Consider always emitting a string (e.g., just use exitCode.ToString(CultureInfo.InvariantCulture) since exitCode is already set to the final value in the catch).

Suggested change
{ TelemetryProperties.HostProperties.ExitCodePropertyName, cancellationToken.IsCancellationRequested ? (int)ExitCodes.TestSessionAborted : exitCode.ToString(CultureInfo.InvariantCulture) },
{ TelemetryProperties.HostProperties.ExitCodePropertyName, exitCode.ToString(CultureInfo.InvariantCulture) },

Copilot uses AI. Check for mistakes.
};

if (statistics is not null)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -170,8 +170,8 @@ or InvalidOperationException

// If the global cancellation is called together with the server closing one the server exited gracefully.
return !cancellationToken.IsCancellationRequested && _serverClosingTokenSource.IsCancellationRequested
? ExitCodes.Success
: ExitCodes.TestSessionAborted;
? (int)ExitCodes.Success
: (int)ExitCodes.TestSessionAborted;
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,7 @@ public async Task<IHost> BuildAsync(

builderActivity?.SetTag(BuilderHostTypeOTelKey, nameof(InformativeCommandLineHost));
builderActivity?.Dispose();
return new InformativeCommandLineHost(ExitCodes.InvalidCommandLine, serviceProvider);
return new InformativeCommandLineHost((int)ExitCodes.InvalidCommandLine, serviceProvider);
}

// Register as ICommandLineOptions.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ protected override async Task<int> InternalRunAsync(CancellationToken cancellati

await outputDevice.DisplayAsync(this, new ErrorMessageOutputDeviceData(displayErrorMessageBuilder.ToString()), cancellationToken).ConfigureAwait(false);
await _logger.LogErrorAsync(logErrorMessageBuilder.ToString()).ConfigureAwait(false);
return ExitCodes.InvalidPlatformSetup;
return (int)ExitCodes.InvalidPlatformSetup;
}

foreach (EnvironmentVariable envVar in environmentVariables.GetAll())
Expand Down Expand Up @@ -346,17 +346,17 @@ protected override async Task<int> InternalRunAsync(CancellationToken cancellati

// If we have a process in the middle between the test host controller and the test host process we need to keep it into account.
exitCode = testHostProcess.ExitCode;
if (exitCode == ExitCodes.Success && cancellationToken.IsCancellationRequested)
if (exitCode == (int)ExitCodes.Success && cancellationToken.IsCancellationRequested)
{
// In case of cancellation, only alter exit code if it was success.
// If there is another exit code indicating another failure, we prefer it over the cancellation.
exitCode = ExitCodes.TestSessionAborted;
exitCode = (int)ExitCodes.TestSessionAborted;
}
else if (!testHostProcessInformation.HasExitedGracefully ||
_testHostExitCodeReceived != testHostProcess.ExitCode)
{
await outputDevice.DisplayAsync(this, new ErrorMessageOutputDeviceData(string.Format(CultureInfo.InvariantCulture, PlatformResources.TestProcessDidNotExitGracefullyErrorMessage, testHostProcess.ExitCode)), cancellationToken).ConfigureAwait(false);
exitCode = ExitCodes.TestHostProcessExitedNonGracefully;
exitCode = (int)ExitCodes.TestHostProcessExitedNonGracefully;
}

await _logger.LogInformationAsync($"TestHostControllersTestHost ended with exit code '{exitCode}' (real test host exit code '{testHostProcess.ExitCode}') in '{consoleRunStarted.Elapsed}'").ConfigureAwait(false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ public async Task<int> RunAsync()
catch (OperationCanceledException) when (applicationCancellationToken.CancellationToken.IsCancellationRequested)
{
// We do nothing we're canceling
exitCode = ExitCodes.TestSessionAborted;
exitCode = (int)ExitCodes.TestSessionAborted;
}

return exitCode;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,20 +63,20 @@ public async Task<int> RunAsync()
{
await _outputDevice.DisplayAsync(this, new ErrorMessageOutputDeviceData(unknownOptionsError), cancellationToken).ConfigureAwait(false);
console.WriteLine();
return ExitCodes.InvalidCommandLine;
return (int)ExitCodes.InvalidCommandLine;
}

if (ExtensionArgumentArityAreInvalid(out string? arityErrors, tool))
{
await _outputDevice.DisplayAsync(this, new ErrorMessageOutputDeviceData(arityErrors), cancellationToken).ConfigureAwait(false);
return ExitCodes.InvalidCommandLine;
return (int)ExitCodes.InvalidCommandLine;
}

ValidationResult optionsArgumentsValidationResult = await ValidateOptionsArgumentsAsync(tool).ConfigureAwait(false);
if (!optionsArgumentsValidationResult.IsValid)
{
await _outputDevice.DisplayAsync(this, new ErrorMessageOutputDeviceData(optionsArgumentsValidationResult.ErrorMessage), cancellationToken).ConfigureAwait(false);
return ExitCodes.InvalidCommandLine;
return (int)ExitCodes.InvalidCommandLine;
}

return await tool.RunAsync(cancellationToken).ConfigureAwait(false);
Expand All @@ -85,7 +85,7 @@ public async Task<int> RunAsync()

await _outputDevice.DisplayAsync(this, new ErrorMessageOutputDeviceData($"Tool '{toolNameToRun}' not found in the list of registered tools."), cancellationToken).ConfigureAwait(false);
await _commandLineHandler.PrintHelpAsync(_outputDevice, null, cancellationToken).ConfigureAwait(false);
return ExitCodes.InvalidCommandLine;
return (int)ExitCodes.InvalidCommandLine;
}

private bool UnknownOptions([NotNullWhen(true)] out string? error, ITool tool)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ public async Task<TResponse> RequestReplyAsync<TRequest, TResponse>(TRequest req
// This is especially important for 'dotnet test', where the user can simply kill the dotnet.exe process themselves.
// In that case, we want the MTP process to also die.
// Exit code 1 indicates abnormal termination due to IPC connection loss.
_environment.Exit(ExitCodes.GenericFailure);
_environment.Exit((int)ExitCodes.GenericFailure);
}

// Reset the current chunk size
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,16 +129,16 @@ public Task ConsumeAsync(IDataProducer dataProducer, IData value, CancellationTo

public int GetProcessExitCode()
{
int exitCode = ExitCodes.Success;
exitCode = exitCode == ExitCodes.Success && _policiesService.IsMaxFailedTestsTriggered ? ExitCodes.TestExecutionStoppedForMaxFailedTests : exitCode;
exitCode = exitCode == ExitCodes.Success && _testAdapterTestSessionFailure ? ExitCodes.TestAdapterTestSessionFailure : exitCode;
exitCode = exitCode == ExitCodes.Success && _failedTestsCount > 0 ? ExitCodes.AtLeastOneTestFailed : exitCode;
exitCode = exitCode == ExitCodes.Success && _policiesService.IsAbortTriggered ? ExitCodes.TestSessionAborted : exitCode;
exitCode = exitCode == ExitCodes.Success && _totalRanTests == 0 ? ExitCodes.ZeroTests : exitCode;
int exitCode = (int)ExitCodes.Success;
exitCode = exitCode == (int)ExitCodes.Success && _policiesService.IsMaxFailedTestsTriggered ? (int)ExitCodes.TestExecutionStoppedForMaxFailedTests : exitCode;
exitCode = exitCode == (int)ExitCodes.Success && _testAdapterTestSessionFailure ? (int)ExitCodes.TestAdapterTestSessionFailure : exitCode;
exitCode = exitCode == (int)ExitCodes.Success && _failedTestsCount > 0 ? (int)ExitCodes.AtLeastOneTestFailed : exitCode;
exitCode = exitCode == (int)ExitCodes.Success && _policiesService.IsAbortTriggered ? (int)ExitCodes.TestSessionAborted : exitCode;
exitCode = exitCode == (int)ExitCodes.Success && _totalRanTests == 0 ? (int)ExitCodes.ZeroTests : exitCode;
Comment on lines +132 to +137
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GetProcessExitCode() now has a lot of repeated (int)ExitCodes.Success casts and comparisons, which makes the exit-code decision logic harder to read and maintain. Consider keeping the working variable typed as ExitCodes (and comparing against ExitCodes.Success), then cast once at the end when returning the int process exit code.

Copilot uses AI. Check for mistakes.

if (_commandLineOptions.TryGetOptionArgumentList(PlatformCommandLineProvider.MinimumExpectedTestsOptionKey, out string[]? argumentList))
{
exitCode = exitCode == ExitCodes.Success && _totalRanTests < int.Parse(argumentList[0], CultureInfo.InvariantCulture) ? ExitCodes.MinimumExpectedTestsPolicyViolation : exitCode;
exitCode = exitCode == (int)ExitCodes.Success && _totalRanTests < int.Parse(argumentList[0], CultureInfo.InvariantCulture) ? (int)ExitCodes.MinimumExpectedTestsPolicyViolation : exitCode;
}

// If the user has specified the IgnoreExitCode, then we don't want to return a non-zero exit code if the exit code matches the one specified.
Expand All @@ -155,7 +155,7 @@ public int GetProcessExitCode()
{
if (exitCodeToIgnore.Split(';').Any(code => int.TryParse(code, out int parsedExitCode) && parsedExitCode == exitCode))
{
exitCode = ExitCodes.Success;
exitCode = (int)ExitCodes.Success;
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ public async Task RunTests_With_MSTestRunner_Standalone_Selectively_Enabled_Exte
testHostResult.AssertOutputContainsSummary(0, 1, 0);

testHostResult = await testHost.ExecuteAsync(command: invalidCommandLineArg, cancellationToken: TestContext.CancellationToken);
Assert.AreEqual(ExitCodes.InvalidCommandLine, testHostResult.ExitCode);
Assert.AreEqual((int)ExitCodes.InvalidCommandLine, testHostResult.ExitCode);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Assertion] This PR adds a typed AssertExitCodeIs(ExitCodes exitCode) overload on TestHostResult in AcceptanceAssert.cs that produces a richer failure message (via GenerateFailedAssertionMessage, which includes the full test host standard output/error). However, this site (and line 286 below, plus ExecutionRequestCompleteTests.cs line 19) still uses a raw Assert.AreEqual((int)ExitCodes.xxx, testHostResult.ExitCode), which on failure only reports the integer values with no host output context.

Impact: Harder to diagnose CI failures — the assertion failure message won't include the test host output that shows why the exit code was wrong.

Suggestion: Replace with the typed overload for a more informative failure message:

// instead of:
Assert.AreEqual((int)ExitCodes.InvalidCommandLine, testHostResult.ExitCode);
// use:
testHostResult.AssertExitCodeIs(ExitCodes.InvalidCommandLine);

}
}

Expand Down Expand Up @@ -283,7 +283,7 @@ public async Task RunTests_With_MSTestRunner_Standalone_Enable_Default_Extension
}
else
{
Assert.AreEqual(ExitCodes.InvalidCommandLine, testHostResult.ExitCode);
Assert.AreEqual((int)ExitCodes.InvalidCommandLine, testHostResult.ExitCode);
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ public async Task ProgressAndControllerOutputAreNotFullySynchronizedAcrossProces
$"\"{testHost.FullName}\" --ignore-exit-code 8",
cancellationToken: TestContext.CancellationToken);

Assert.AreEqual(ExitCodes.Success, exitCode);
Assert.AreEqual((int)ExitCodes.Success, exitCode);
Assert.Contains("Slowest 10 tests", commandLine.StandardOutput);
}

Expand Down
Loading
Loading