// 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.Acceptance.IntegrationTests;
///
/// Exercises the out-of-process (TestHostControllersTestHost) coverage-threshold exit-code override
/// end to end. A coverage threshold message is published by an ITestHostProcessLifetimeHandler
/// running in the controller process (a separate code path from the in-process ConsoleTestHost):
/// the controller registers the coverage-result consumer, drains the message queue after the test host
/// exits, and turns an otherwise-successful run into (14)
/// when a threshold failed, while a passed threshold leaves the run successful.
///
[TestClass]
public sealed class CoverageThresholdControllerExitCodeTests : AcceptanceTestBase
{
private const string AssetName = "CoverageThresholdControllerExitCode";
[TestMethod]
public async Task ConfiguredThreshold_AzureSummaryOutsidePipelines_DoesNotInvokeDisabledProcessor()
{
using TempDirectory results = new();
string summaryPath = Path.Combine(results.Path, "summary.md");
var testHost = TestInfrastructure.TestHost.LocateFrom(AssetFixture.TargetAssetPath, AssetName, TargetFrameworks.NetCurrent);
TestHostResult result = await testHost.ExecuteAsync(
$"--coverage-threshold-line 81 --report-azdo --report-azdo-summary \"{summaryPath}\" --results-directory \"{results.Path}\"",
environmentVariables: new Dictionary
{
["COVERAGE_MEASUREMENTS"] = "normal",
["CI_SUMMARY_PROVIDER"] = "azure",
["TF_BUILD"] = null,
},
cancellationToken: TestContext.CancellationToken);
result.AssertExitCodeIs(ExitCode.CoverageThresholdFailed);
result.AssertOutputContains("TF_BUILD is not set to 'true'; skipping summary emission.");
Assert.IsFalse(File.Exists(summaryPath));
Assert.IsFalse(Directory.Exists(Path.Combine(results.Path, ".ci-summary-fragments")));
}
[TestMethod]
[DataRow("github", "80", (int)ExitCode.Success)]
[DataRow("github", "81", (int)ExitCode.CoverageThresholdFailed)]
[DataRow("azure", "80", (int)ExitCode.Success)]
[DataRow("azure", "81", (int)ExitCode.CoverageThresholdFailed)]
public async Task ConfiguredThreshold_InController_ReachesCiSummary(string provider, string threshold, int expectedExitCode)
{
using TempDirectory results = new();
string summaryPath = Path.Combine(results.Path, "summary.md");
var testHost = TestInfrastructure.TestHost.LocateFrom(AssetFixture.TargetAssetPath, AssetName, TargetFrameworks.NetCurrent);
string reportingOptions = provider == "github" ? "--report-gh" : $"--report-azdo --report-azdo-summary \"{summaryPath}\"";
TestHostResult result = await testHost.ExecuteAsync(
$"--coverage-threshold-line {threshold} {reportingOptions} --results-directory \"{results.Path}\"",
environmentVariables: new Dictionary
{
["COVERAGE_MEASUREMENTS"] = "normal",
["CI_SUMMARY_PROVIDER"] = provider,
["GITHUB_ACTIONS"] = provider == "github" ? "true" : null,
["GITHUB_STEP_SUMMARY"] = summaryPath,
["TF_BUILD"] = provider == "azure" ? "true" : null,
},
cancellationToken: TestContext.CancellationToken);
result.AssertExitCodeIs((ExitCode)expectedExitCode);
Assert.IsTrue(File.Exists(summaryPath), result.ToString());
string summary = File.ReadAllText(summaryPath).ReplaceLineEndings("\n");
string verdict = expectedExitCode == 0 ? "✅ Passed" : "❌ Failed";
Assert.Contains(
$"""
| Scope | Metric | Actual | Required | Result |
| --- | --- | ---: | ---: | --- |
| {AssetName} ({TargetFrameworks.NetCurrent}) — Overall | Line | 80.0% | {threshold}.0% | {verdict} |
""".ReplaceLineEndings("\n"),
summary);
}
[TestMethod]
[DataRow("--coverage-threshold-line 80 --coverage-threshold-branch 70", "normal", false, (int)ExitCode.Success)]
[DataRow("--coverage-threshold-line 81", "normal", false, (int)ExitCode.CoverageThresholdFailed)]
[DataRow("--coverage-threshold-branch 71", "normal", false, (int)ExitCode.CoverageThresholdFailed)]
[DataRow("--coverage-threshold-line 80", "missing", false, (int)ExitCode.CoverageThresholdFailed)]
[DataRow("--coverage-threshold-line 0", "empty", false, (int)ExitCode.CoverageThresholdFailed)]
[DataRow("--coverage-threshold-line 81", "normal", true, (int)ExitCode.AtLeastOneTestFailed)]
[DataRow("--coverage-threshold-line 81 --ignore-exit-code 14", "normal", false, (int)ExitCode.Success)]
[DataRow("--coverage-threshold-line 81 --ignore-exit-code 2", "normal", true, (int)ExitCode.Success)]
[DataRow("--coverage-threshold-line 80", "testhost", false, (int)ExitCode.Success)]
public async Task ConfiguredThreshold_WaitsForControllerCollection(string command, string mode, bool failTest, int expectedExitCode)
{
var testHost = TestInfrastructure.TestHost.LocateFrom(AssetFixture.TargetAssetPath, AssetName, TargetFrameworks.NetCurrent);
TestHostResult result = await testHost.ExecuteAsync(
command,
environmentVariables: new Dictionary
{
["COVERAGE_MEASUREMENTS"] = mode,
["FAIL_TEST"] = failTest ? "1" : "0",
},
cancellationToken: TestContext.CancellationToken);
result.AssertExitCodeIs((ExitCode)expectedExitCode);
result.AssertOutputContains("Coverage Threshold Results:");
if (mode != "missing")
{
Assert.DoesNotContain("Enable a compatible coverage collector", result.StandardOutput);
}
}
[DynamicData(nameof(TargetFrameworks.AllForDynamicData), typeof(TargetFrameworks))]
[TestMethod]
public async Task FailedThreshold_InController_ReturnsCoverageThresholdFailedExitCode(string currentTfm)
{
var testHost = TestInfrastructure.TestHost.LocateFrom(AssetFixture.TargetAssetPath, AssetName, currentTfm);
TestHostResult testHostResult = await testHost.ExecuteAsync(
environmentVariables: new Dictionary
{
["COVERAGE_THRESHOLD_STATUS"] = "Failed",
},
cancellationToken: TestContext.CancellationToken);
testHostResult.AssertExitCodeIs(ExitCode.CoverageThresholdFailed);
testHostResult.AssertOutputContains("Coverage Threshold Results:");
testHostResult.AssertOutputContains("Total - Line (Minimum over Module): 70.0% < 80.0% threshold");
}
[DynamicData(nameof(TargetFrameworks.AllForDynamicData), typeof(TargetFrameworks))]
[TestMethod]
public async Task PassedThreshold_InController_ReturnsSuccess(string currentTfm)
{
var testHost = TestInfrastructure.TestHost.LocateFrom(AssetFixture.TargetAssetPath, AssetName, currentTfm);
TestHostResult testHostResult = await testHost.ExecuteAsync(
environmentVariables: new Dictionary
{
["COVERAGE_THRESHOLD_STATUS"] = "Passed",
},
cancellationToken: TestContext.CancellationToken);
testHostResult.AssertExitCodeIs(ExitCode.Success);
testHostResult.AssertOutputContains("Coverage Threshold Results:");
testHostResult.AssertOutputContains("Total - Line (Minimum over Module): 90.0% >= 80.0% threshold");
}
[DynamicData(nameof(TargetFrameworks.AllForDynamicData), typeof(TargetFrameworks))]
[TestMethod]
public async Task FailedThreshold_InController_WithIgnoreExitCode_ReturnsSuccess(string currentTfm)
{
var testHost = TestInfrastructure.TestHost.LocateFrom(AssetFixture.TargetAssetPath, AssetName, currentTfm);
TestHostResult testHostResult = await testHost.ExecuteAsync(
command: $"--ignore-exit-code {(int)ExitCode.CoverageThresholdFailed}",
environmentVariables: new Dictionary
{
["COVERAGE_THRESHOLD_STATUS"] = "Failed",
},
cancellationToken: TestContext.CancellationToken);
// The controller-side coverage-threshold verdict must also honor '--ignore-exit-code 14'.
testHostResult.AssertExitCodeIs(ExitCode.Success);
testHostResult.AssertOutputContains("Coverage Threshold Results:");
testHostResult.AssertOutputContains("Total - Line (Minimum over Module): 70.0% < 80.0% threshold");
}
[DynamicData(nameof(TargetFrameworks.AllForDynamicData), typeof(TargetFrameworks))]
[TestMethod]
public async Task FailedThreshold_InController_WithIgnoredFailingTest_ReturnsSuccess(string currentTfm)
{
var testHost = TestInfrastructure.TestHost.LocateFrom(AssetFixture.TargetAssetPath, AssetName, currentTfm);
TestHostResult testHostResult = await testHost.ExecuteAsync(
command: $"--ignore-exit-code {(int)ExitCode.AtLeastOneTestFailed}",
environmentVariables: new Dictionary
{
["COVERAGE_THRESHOLD_STATUS"] = "Failed",
["FAIL_TEST"] = "1",
},
cancellationToken: TestContext.CancellationToken);
testHostResult.AssertExitCodeIs(ExitCode.Success);
}
public sealed class TestAssetFixture() : TestAssetFixtureBase()
{
private const string Sources = """
#file CoverageThresholdControllerExitCode.csproj
$TargetFrameworks$
Exe
true
enable
preview
$(NoWarn);NETSDK1201
#file Program.cs
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Testing.Extensions;
using Microsoft.Testing.Platform.Builder;
using Microsoft.Testing.Platform.Capabilities.TestFramework;
using Microsoft.Testing.Platform.Extensions;
using Microsoft.Testing.Platform.Extensions.Messages;
using Microsoft.Testing.Platform.Extensions.TestFramework;
using Microsoft.Testing.Platform.Extensions.TestHostControllers;
using Microsoft.Testing.Platform.Messages;
using Microsoft.Testing.Platform.Services;
using Microsoft.Testing.Platform.TestHost;
public class Startup
{
public static async Task Main(string[] args)
{
var testApplicationBuilder = await TestApplication.CreateBuilderAsync(args);
testApplicationBuilder.RegisterTestFramework(_ => new TestFrameworkCapabilities(), (_, __) => new DummyTestFramework());
string? summaryProvider = Environment.GetEnvironmentVariable("CI_SUMMARY_PROVIDER");
if (summaryProvider == "github")
{
testApplicationBuilder.AddGitHubActionsProvider();
}
else if (summaryProvider == "azure")
{
testApplicationBuilder.AddAzureDevOpsProvider();
}
// Registering a test host controller extension makes the platform run in controller mode:
// a separate test host process runs the tests and this (controller) process publishes the
// coverage threshold result and evaluates the exit code override.
testApplicationBuilder.TestHostControllers.AddProcessLifetimeHandler(serviceProvider =>
new CoverageThresholdLifetimeHandler(serviceProvider.GetMessageBus()));
using ITestApplication app = await testApplicationBuilder.BuildAsync();
return await app.RunAsync();
}
}
public class CoverageThresholdLifetimeHandler : ITestHostProcessLifetimeHandler, IDataProducer
{
private readonly IMessageBus _messageBus;
public CoverageThresholdLifetimeHandler(IMessageBus messageBus)
=> _messageBus = messageBus;
public string Uid => nameof(CoverageThresholdLifetimeHandler);
public string Version => "1.0.0";
public string DisplayName => nameof(CoverageThresholdLifetimeHandler);
public string Description => nameof(CoverageThresholdLifetimeHandler);
public Type[] DataTypesProduced => Environment.GetEnvironmentVariable("COVERAGE_MEASUREMENTS") == "testhost"
? Array.Empty()
: new[] { typeof(TestCoverageThresholdMessage), typeof(TestCoverageMessage) };
public Task IsEnabledAsync() => Task.FromResult(true);
public Task BeforeTestHostProcessStartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public Task OnTestHostProcessStartedAsync(ITestHostProcessInformation testHostProcessInformation, CancellationToken cancellationToken) => Task.CompletedTask;
public async Task OnTestHostProcessExitedAsync(ITestHostProcessInformation testHostProcessInformation, CancellationToken cancellationToken)
{
string? thresholdStatus = Environment.GetEnvironmentVariable("COVERAGE_THRESHOLD_STATUS");
var sessionUid = new SessionUid("controller");
string? measurements = Environment.GetEnvironmentVariable("COVERAGE_MEASUREMENTS");
if (measurements is "normal" or "empty")
{
await _messageBus.PublishAsync(this, new TestCoverageMessage(
sessionUid, CoverageScope.Overall, CoverageMetric.Line,
measurements == "empty" ? 0 : 80, measurements == "empty" ? 0 : 100, Uid));
await _messageBus.PublishAsync(this, new TestCoverageMessage(
sessionUid, CoverageScope.Overall, CoverageMetric.Branch, 70, 100, Uid));
}
if (thresholdStatus == "Failed")
{
await _messageBus.PublishAsync(this, new TestCoverageThresholdMessage(
sessionUid, CoverageScope.Overall, CoverageMetric.Line, CoverageAggregation.Minimum,
actualPercentage: 70.0, requiredPercentage: 80.0, hasCoverableData: true, producerId: nameof(CoverageThresholdLifetimeHandler),
aggregatedOver: CoverageScopeLevel.Module));
}
else if (thresholdStatus == "Passed")
{
await _messageBus.PublishAsync(this, new TestCoverageThresholdMessage(
sessionUid, CoverageScope.Overall, CoverageMetric.Line, CoverageAggregation.Minimum,
actualPercentage: 90.0, requiredPercentage: 80.0, hasCoverableData: true, producerId: nameof(CoverageThresholdLifetimeHandler),
aggregatedOver: CoverageScopeLevel.Module));
}
}
}
public class DummyTestFramework : ITestFramework, IDataProducer
{
public string Uid => nameof(DummyTestFramework);
public string Version => "2.0.0";
public string DisplayName => nameof(DummyTestFramework);
public string Description => nameof(DummyTestFramework);
public Task IsEnabledAsync() => Task.FromResult(true);
public Type[] DataTypesProduced => new[] { typeof(TestNodeUpdateMessage), typeof(TestCoverageMessage) };
public Task CreateTestSessionAsync(CreateTestSessionContext context)
=> Task.FromResult(new CreateTestSessionResult() { IsSuccess = true });
public Task CloseTestSessionAsync(CloseTestSessionContext context)
=> Task.FromResult(new CloseTestSessionResult() { IsSuccess = true });
public async Task ExecuteRequestAsync(ExecuteRequestContext context)
{
IProperty state = Environment.GetEnvironmentVariable("FAIL_TEST") == "1"
? new FailedTestNodeStateProperty()
: new PassedTestNodeStateProperty();
await context.MessageBus.PublishAsync(this, new TestNodeUpdateMessage(context.Request.Session.SessionUid, new TestNode()
{
Uid = "Test1",
DisplayName = "Test1",
Properties = new PropertyBag(state),
}));
if (Environment.GetEnvironmentVariable("COVERAGE_MEASUREMENTS") == "testhost")
{
await context.MessageBus.PublishAsync(this, new TestCoverageMessage(
context.Request.Session.SessionUid, CoverageScope.Overall, CoverageMetric.Line, 80, 100, Uid));
}
context.Complete();
}
}
""";
public string TargetAssetPath => GetAssetPath(AssetName);
public override (string ID, string Name, string Code) GetAssetsToGenerate() => (AssetName, AssetName,
Sources
.PatchTargetFrameworks(TargetFrameworks.All)
.PatchCodeWithReplace("$MicrosoftTestingExtensionsGitHubActionsReportVersion$", MicrosoftTestingExtensionsGitHubActionsReportVersion)
.PatchCodeWithReplace("$MicrosoftTestingPlatformVersion$", MicrosoftTestingPlatformVersion));
}
public TestContext TestContext { get; set; }
}