// 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.CommandLine; using Microsoft.Testing.Platform.Extensions.Messages; using Microsoft.Testing.Platform.Extensions.OutputDevice; using Microsoft.Testing.Platform.Helpers; using Microsoft.Testing.Platform.OutputDevice; using Microsoft.Testing.Platform.Resources; using Microsoft.Testing.Platform.Telemetry; namespace Microsoft.Testing.Platform.Services; internal sealed class TestApplicationResult : ITestApplicationProcessExitCode, IOutputDeviceDataProducer, ITestExecutionActivityContextConsumer, IDisposable { private readonly IOutputDevice _outputService; private readonly ICommandLineOptions _commandLineOptions; private readonly IEnvironment _environment; private readonly IStopPoliciesService _policiesService; private readonly ITestCoverageResult? _testCoverageResult; private readonly OpenTelemetryResultHandler? _openTelemetryResultHandler; private readonly bool _isDiscovery; private int _failedTestsCount; private int _totalRanTests; private int _skippedTestsCount; private bool _testAdapterTestSessionFailure; public TestApplicationResult( IOutputDevice outputService, ICommandLineOptions commandLineOptions, IEnvironment environment, IStopPoliciesService policiesService, IPlatformOpenTelemetryService? otelService) : this(outputService, commandLineOptions, environment, policiesService, otelService, testCoverageResult: null) { } public TestApplicationResult( IOutputDevice outputService, ICommandLineOptions commandLineOptions, IEnvironment environment, IStopPoliciesService policiesService, IPlatformOpenTelemetryService? otelService, ITestCoverageResult? testCoverageResult) { _outputService = outputService; _commandLineOptions = commandLineOptions; _environment = environment; _policiesService = policiesService; _testCoverageResult = testCoverageResult; if (otelService is not null) { _openTelemetryResultHandler = new OpenTelemetryResultHandler(otelService, PlatformOpenTelemetryOptions.FromEnvironment(environment)); } _isDiscovery = _commandLineOptions.IsOptionSet(PlatformCommandLineProvider.DiscoverTestsOptionKey); } /// public string Uid => nameof(TestApplicationResult); /// public string Version => PlatformVersion.Version; /// public string DisplayName { get; } = PlatformResources.TestApplicationResultDisplayName; /// public string Description { get; } = PlatformResources.TestApplicationResultDescription; /// public Type[] DataTypesConsumed { get; } = [typeof(TestNodeUpdateMessage)]; public bool HasTestAdapterTestSessionFailure => TestAdapterTestSessionFailureErrorMessage is not null; public string? TestAdapterTestSessionFailureErrorMessage { get; private set; } /// public Task IsEnabledAsync() => Task.FromResult(true); public Task ConsumeAsync(IDataProducer dataProducer, IData value, CancellationToken cancellationToken) => ConsumeAsync(value, executionActivityContext: null); Task ITestExecutionActivityContextConsumer.ConsumeAsync( IDataProducer dataProducer, IData data, PlatformActivityContext? executionActivityContext, CancellationToken cancellationToken) => ConsumeAsync(data, executionActivityContext); private Task ConsumeAsync(IData value, PlatformActivityContext? executionActivityContext) { var message = (TestNodeUpdateMessage)value; TestNodeStateProperty? executionState = message.TestNode.Properties.SingleOrDefault(); if (message.TestNode.Properties.Any()) { _openTelemetryResultHandler?.NotifyExecutionCompleted(message.TestNode); return Task.CompletedTask; } if (executionState is null) { return Task.CompletedTask; } // A test framework that retries a test in-process (MSTest's [Retry], ...) can report every attempt under // the same test node uid. In-progress updates still need to start their attempt span, and superseded // terminal updates need to complete it, but only the final attempt contributes to aggregate metrics and // the process outcome. if (message.TestNode.IsSupersededRetryAttempt() && executionState is not InProgressTestNodeStateProperty) { _openTelemetryResultHandler?.NotifySupersededRetryAttempt(message.TestNode, executionState); return Task.CompletedTask; } switch (executionState) { case DiscoveredTestNodeStateProperty: _openTelemetryResultHandler?.NotifyDiscovered(); // In discovery mode, discovered tests count toward the "ran" total. if (_isDiscovery) { _totalRanTests++; } break; case PassedTestNodeStateProperty passed: _openTelemetryResultHandler?.NotifyPassed(message.TestNode, passed); _totalRanTests++; break; case FailedTestNodeStateProperty: case ErrorTestNodeStateProperty: case TimeoutTestNodeStateProperty: #pragma warning disable CS0618, MTP0001 // Type or member is obsolete case CancelledTestNodeStateProperty: #pragma warning restore CS0618, MTP0001 // Type or member is obsolete _openTelemetryResultHandler?.NotifyFailed(message.TestNode, executionState); _failedTestsCount++; _totalRanTests++; break; case SkippedTestNodeStateProperty skipped: _openTelemetryResultHandler?.NotifySkipped(message.TestNode, skipped); _skippedTestsCount++; // DESIGN: Skipped tests are intentionally excluded from `_totalRanTests`. Whether an all-skipped // run is treated as "ran nothing" depends on the `--zero-tests-policy` option resolved in // `GetProcessExitCode`: // - `allow-skipped` (#9385, the default): skipped count as run, so only a run where nothing was // discovered (`_totalRanTests == 0 && _skippedTestsCount == 0`) yields exit code // `ExitCode.ZeroTests` (8). An all-skipped run succeeds. // - `strict`: skipped don't count, so an all-skipped (or zero-test) run leaves // `_totalRanTests == 0` and yields exit code 8. This is the original behavior from #3216 / #3243 // ("Skipped tests count as not run") which surfaced the common "invalid filter ran nothing" mistake. // The documented blunt opt-out for any of exit code 8 remains `--ignore-exit-code 8`. // // Two other layers mirror this decision and must stay in lockstep: // - TerminalTestReporter.Summary.cs / TestRunSummaryHelper (`allTestsWereSkipped`) // - Microsoft.Testing.Platform.MSBuild InvokeTestingPlatformTask (run-summary verdict) // Do not change this without revisiting those sites and the design discussion above. break; case InProgressTestNodeStateProperty: _openTelemetryResultHandler?.NotifyInProgress( message.TestNode, message.ParentTestNodeUid, executionActivityContext); break; default: _openTelemetryResultHandler?.NotifyUnknown(); break; } return Task.CompletedTask; } public int GetProcessExitCode() => ExitCodeIgnorePolicy.Apply(GetProcessExitCodeWithoutIgnore(), _commandLineOptions, _environment); internal int GetProcessExitCodeWithoutIgnore() { ExitCode exitCode = ExitCode.Success; exitCode = exitCode == ExitCode.Success && _policiesService.IsMaxFailedTestsTriggered ? ExitCode.TestExecutionStoppedForMaxFailedTests : exitCode; exitCode = exitCode == ExitCode.Success && _testAdapterTestSessionFailure ? ExitCode.TestAdapterTestSessionFailure : exitCode; exitCode = exitCode == ExitCode.Success && _failedTestsCount > 0 ? ExitCode.AtLeastOneTestFailed : exitCode; exitCode = exitCode == ExitCode.Success && _policiesService.IsAbortTriggered ? ExitCode.TestSessionAborted : exitCode; // A deadline-driven graceful stop (see AbortAtDeadlineExtension) truncates the run before the CI // hard-cancel. Such a run may otherwise look successful (it did not fail or abort), but it did not // execute every test, so it must not report success. Real failures/abort above keep precedence; a // clean-but-truncated run becomes non-zero here and takes precedence over the zero-tests/coverage // verdicts below (a truncated run legitimately may not have run the expected number of tests). exitCode = exitCode == ExitCode.Success && _policiesService.IsDeadlineTriggered ? ExitCode.TestExecutionStoppedAtDeadline : exitCode; // An explicitly-provided `--minimum-expected-tests` governs the count-based verdict and // supersedes the ZeroTests (8) verdict below: a run of fewer than N tests yields // ExitCode.MinimumExpectedTestsPolicyViolation (9), even when zero tests ran. This lets callers // tell an explicit-minimum violation apart from a plain "ran nothing" run (e.g. so a // `dotnet test --test-modules` orchestrator can distinguish a stricter local minimum from an // empty module). See issue #7457. // A malformed value (e.g. present with no argument) is rejected earlier by option validation, but // we still guard the parse defensively and fall back to the zero-tests verdict if it ever slips through. if (_commandLineOptions.TryGetOptionArgumentList(PlatformCommandLineProvider.MinimumExpectedTestsOptionKey, out string[]? argumentList) && argumentList is [string minimumExpectedTestsArgument] && int.TryParse(minimumExpectedTestsArgument, out int minimumExpectedTests)) { exitCode = exitCode == ExitCode.Success && _totalRanTests < minimumExpectedTests ? ExitCode.MinimumExpectedTestsPolicyViolation : exitCode; } else { // Determine whether the run should be treated as having executed zero tests. Skipped tests are excluded // from `_totalRanTests`. Under the default `allow-skipped` policy (#9385) skipped tests count as run, so only // a run that discovered nothing at all counts as zero tests; under `strict` an all-skipped run also counts // as zero tests. ZeroTestsPolicy zeroTestsPolicy = PlatformCommandLineProvider.GetZeroTestsPolicy(_commandLineOptions); bool ranZeroTests = zeroTestsPolicy == ZeroTestsPolicy.AllowSkipped ? _totalRanTests == 0 && _skippedTestsCount == 0 : _totalRanTests == 0; exitCode = exitCode == ExitCode.Success && ranZeroTests ? ExitCode.ZeroTests : exitCode; } // Coverage thresholds override only an otherwise-successful run. Count-based policies above retain // precedence when no tests ran or the explicit minimum was not met. exitCode = exitCode == ExitCode.Success && _testCoverageResult?.HasThresholdFailure == true ? ExitCode.CoverageThresholdFailed : exitCode; return (int)exitCode; } public async Task SetTestAdapterTestSessionFailureAsync(string errorMessage, CancellationToken cancellationToken) { TestAdapterTestSessionFailureErrorMessage = errorMessage; _testAdapterTestSessionFailure = true; await _outputService.DisplayAsync(this, new ErrorMessageOutputDeviceData(errorMessage), cancellationToken).ConfigureAwait(false); } public Statistics GetStatistics() => new() { TotalRanTests = _totalRanTests, TotalFailedTests = _failedTestsCount }; /// /// Emits the run-level OpenTelemetry metrics and tags the root span with the run counts. /// /// The root span of the run, if any. /// The exit code the host is about to return. Passed in rather than recomputed so the /// telemetry always agrees with what the process actually exits with, including on the cancellation path. /// /// This deliberately does not live in : services are disposed in registration order, and the /// OpenTelemetry provider is registered long before this one, so by the time we were disposed the /// MeterProvider had already been shut down and the measurement was silently dropped. The host calls this /// from its finally block instead, while the providers and the root span are still alive. /// internal void ReportRunTelemetry(IPlatformActivity? runActivity, int exitCode) => _openTelemetryResultHandler?.NotifyRunCompleted(_totalRanTests, _failedTestsCount, _skippedTestsCount, exitCode, runActivity); public void Dispose() => _openTelemetryResultHandler?.Dispose(); }