Skip to content
Merged
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 @@ -48,7 +48,7 @@ public void SendMessage(TestMessageLevel testMessageLevel, string message)
/// Initializes a new instance of the <see cref="TestExecutionManager"/> class.
/// </summary>
public TestExecutionManager()
: this(new EnvironmentWrapper())
: this(EnvironmentWrapper.Instance)
{
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ internal UnitTestRunner(MSTestSettings settings, UnitTestElement[] testsToRun, R
// This would just be resetting the settings to itself in non desktop workflows.
MSTestSettings.PopulateSettings(settings);

// Bridge the adapter setting to the TestFramework for assertion failure behavior.
AssertionFailureSettings.LaunchDebuggerOnAssertionFailure = MSTestSettings.CurrentSettings.LaunchDebuggerOnAssertionFailure;

Logger.OnLogMessage += message => TestContextImplementation.CurrentTestContext?.WriteConsoleOut(message);
if (MSTestSettings.CurrentSettings.CaptureDebugTraces)
{
Expand Down
41 changes: 41 additions & 0 deletions src/Adapter/MSTestAdapter.PlatformServices/MSTestSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ public MSTestSettings()
TestCleanupTimeout = 0;
CooperativeCancellationTimeout = false;
OrderTestsByNameInClass = false;
LaunchDebuggerOnAssertionFailure = DebuggerLaunchMode.Disabled;
}

/// <summary>
Expand Down Expand Up @@ -185,6 +186,11 @@ public static RunConfigurationSettings RunConfigurationSettings
/// </summary>
internal bool OrderTestsByNameInClass { get; private set; }

/// <summary>
/// Gets a value specifying when to launch the debugger on assertion failure.
/// </summary>
internal DebuggerLaunchMode LaunchDebuggerOnAssertionFailure { get; private set; }

/// <summary>
/// Populate settings based on existing settings object.
/// </summary>
Expand All @@ -210,6 +216,7 @@ public static void PopulateSettings(MSTestSettings settings)
CurrentSettings.TestSettingsFile = settings.TestSettingsFile;
CurrentSettings.TestTimeout = settings.TestTimeout;
CurrentSettings.TreatDiscoveryWarningsAsErrors = settings.TreatDiscoveryWarningsAsErrors;
CurrentSettings.LaunchDebuggerOnAssertionFailure = settings.LaunchDebuggerOnAssertionFailure;
}

#if !WINDOWS_UWP
Expand Down Expand Up @@ -656,6 +663,21 @@ private static MSTestSettings ToSettings(XmlReader reader, IMessageLogger? logge
break;
}

case "LAUNCHDEBUGGERONASSERTIONFAILURE":
{
string value = reader.ReadInnerXml();
if (TryParseEnum(value, out DebuggerLaunchMode mode))
{
settings.LaunchDebuggerOnAssertionFailure = mode;
}
else
{
logger?.SendMessage(TestMessageLevel.Warning, string.Format(CultureInfo.CurrentCulture, Resource.InvalidValue, value, "LaunchDebuggerOnAssertionFailure"));
}

break;
}

default:
{
PlatformServiceProvider.Instance.SettingsProvider.Load(reader.ReadSubtree());
Expand Down Expand Up @@ -783,6 +805,23 @@ private static void ParseBooleanSetting(IConfiguration configuration, string key
}
}

private static void ParseDebuggerLaunchModeSetting(IConfiguration configuration, string key, IMessageLogger? logger, Action<DebuggerLaunchMode> setSetting)
{
if (configuration[$"mstest:{key}"] is not string value)
{
return;
}

if (TryParseEnum(value, out DebuggerLaunchMode mode))
{
setSetting(mode);
}
else
{
logger?.SendMessage(TestMessageLevel.Warning, string.Format(CultureInfo.CurrentCulture, Resource.InvalidValue, value, key));
}
}

private static void ParseIntegerSetting(IConfiguration configuration, string key, IMessageLogger? logger, Action<int> setSetting)
{
if (configuration[$"mstest:{key}"] is not string value)
Expand Down Expand Up @@ -834,6 +873,7 @@ internal static void SetSettingsFromConfig(IConfiguration configuration, IMessag
// "mapNotRunnableToFailed" : true/false
// "treatDiscoveryWarningsAsErrors" : true/false
// "considerEmptyDataSourceAsInconclusive" : true/false
// "launchDebuggerOnAssertionFailure" : "disabled"/"enabled"/"enabledExcludingCI" (or true/false for backward compatibility)
// }
// ... remaining settings
// }
Expand All @@ -848,6 +888,7 @@ internal static void SetSettingsFromConfig(IConfiguration configuration, IMessag
ParseBooleanSetting(configuration, "execution:mapNotRunnableToFailed", logger, value => settings.MapNotRunnableToFailed = value);
ParseBooleanSetting(configuration, "execution:treatDiscoveryWarningsAsErrors", logger, value => settings.TreatDiscoveryWarningsAsErrors = value);
ParseBooleanSetting(configuration, "execution:considerEmptyDataSourceAsInconclusive", logger, value => settings.ConsiderEmptyDataSourceAsInconclusive = value);
ParseDebuggerLaunchModeSetting(configuration, "execution:launchDebuggerOnAssertionFailure", logger, value => settings.LaunchDebuggerOnAssertionFailure = value);

ParseBooleanSetting(configuration, "timeout:useCooperativeCancellation", logger, value => settings.CooperativeCancellationTimeout = value);
ParseIntegerSetting(configuration, "timeout:test", logger, value => settings.TestTimeout = value);
Expand Down
24 changes: 23 additions & 1 deletion src/TestFramework/TestFramework/Assertions/Assert.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,30 @@ private Assert()
[DoesNotReturn]
[StackTraceHidden]
internal static void ThrowAssertFailed(string assertionName, string? message)
=> throw new AssertFailedException(
{
if (ShouldLaunchDebugger())
{
if (Debugger.IsAttached)
{
Debugger.Break();
}
else
{
Debugger.Launch();
}
}

throw new AssertFailedException(
string.Format(CultureInfo.CurrentCulture, FrameworkMessages.AssertionFailed, assertionName, message));
}

private static bool ShouldLaunchDebugger()
=> AssertionFailureSettings.LaunchDebuggerOnAssertionFailure switch
{
DebuggerLaunchMode.Enabled => true,
DebuggerLaunchMode.EnabledExcludingCI => !CIEnvironmentDetector.Instance.IsCIEnvironment(),
_ => false,
};

/// <summary>
/// Builds the formatted message using the given user format message and parameters.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,117 +12,21 @@ namespace Microsoft.VisualStudio.TestTools.UnitTesting;
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, Inherited = false, AllowMultiple = false)]
public sealed class CIConditionAttribute : ConditionBaseAttribute
{
private readonly IEnvironment _environment;

/// <summary>
/// Initializes a new instance of the <see cref="CIConditionAttribute"/> class.
/// </summary>
/// <param name="mode">Decides whether the test should be included or excluded in CI environments.</param>
public CIConditionAttribute(ConditionMode mode)
: this(mode, new EnvironmentWrapper())
{
}

internal CIConditionAttribute(ConditionMode mode, IEnvironment environment)
: base(mode)
{
_environment = environment;
IgnoreMessage = mode == ConditionMode.Include
=> IgnoreMessage = mode == ConditionMode.Include
? "Test is only supported in CI environments"
: "Test is not supported in CI environments";
}

/// <inheritdoc />
public override bool IsConditionMet => IsCIEnvironment();
public override bool IsConditionMet => CIEnvironmentDetector.Instance.IsCIEnvironment();

/// <summary>
/// Gets the group name for this attribute.
/// </summary>
public override string GroupName => nameof(CIConditionAttribute);

// CI Detection logic based on https://learn.microsoft.com/dotnet/core/tools/telemetry#continuous-integration-detection
// From: https://github.com/dotnet/sdk/blob/main/src/Cli/dotnet/Telemetry/CIEnvironmentDetectorForTelemetry.cs
private bool IsCIEnvironment()
{
// Systems that provide boolean values only, so we can simply parse and check for true
string[] booleanVariables =
[
// Azure Pipelines - https://docs.microsoft.com/azure/devops/pipelines/build/variables#system-variables-devops-services
"TF_BUILD",

// GitHub Actions - https://docs.github.com/en/actions/learn-github-actions/environment-variables#default-environment-variables
"GITHUB_ACTIONS",

// AppVeyor - https://www.appveyor.com/docs/environment-variables/
"APPVEYOR",

// A general-use flag - Many of the major players support this: AzDo, GitHub, GitLab, AppVeyor, Travis CI, CircleCI.
"CI",

// Travis CI - https://docs.travis-ci.com/user/environment-variables/#default-environment-variables
"TRAVIS",

// CircleCI - https://circleci.com/docs/2.0/env-vars/#built-in-environment-variables
"CIRCLECI"
];

// Systems where every variable must be present and not-null before returning true
string[][] allNotNullVariables =
[
// AWS CodeBuild - https://docs.aws.amazon.com/codebuild/latest/userguide/build-env-ref-env-vars.html
["CODEBUILD_BUILD_ID", "AWS_REGION"],

// Jenkins - https://github.com/jenkinsci/jenkins/blob/master/core/src/main/resources/jenkins/model/CoreEnvironmentContributor/buildEnv.groovy
["BUILD_ID", "BUILD_URL"],

// Google Cloud Build - https://cloud.google.com/build/docs/configuring-builds/substitute-variable-values#using_default_substitutions
["BUILD_ID", "PROJECT_ID"],
];

// Systems where the variable must be present and not-null
string[] ifNonNullVariables =
[
// TeamCity - https://www.jetbrains.com/help/teamcity/predefined-build-parameters.html#Predefined+Server+Build+Parameters
"TEAMCITY_VERSION",

// JetBrains Space - https://www.jetbrains.com/help/space/automation-environment-variables.html#general
"JB_SPACE_API_URL"
];

foreach (string booleanVariable in booleanVariables)
{
if (bool.TryParse(_environment.GetEnvironmentVariable(booleanVariable), out bool envVar) && envVar)
{
return true;
}
}

foreach (string[] variables in allNotNullVariables)
{
bool allVariablesPresent = true;
foreach (string variable in variables)
{
if (string.IsNullOrEmpty(_environment.GetEnvironmentVariable(variable)))
{
allVariablesPresent = false;
break;
}
}

if (allVariablesPresent)
{
return true;
}
}

foreach (string variable in ifNonNullVariables)
{
if (!string.IsNullOrEmpty(_environment.GetEnvironmentVariable(variable)))
{
return true;
}
}

return false;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

namespace Microsoft.VisualStudio.TestTools.UnitTesting;

/// <summary>
/// Internal settings for assertion failure behavior.
/// This class is used by the test adapter to communicate settings to the framework.
/// </summary>
internal static class AssertionFailureSettings
{
/// <summary>
/// Gets or sets a value specifying when to launch the debugger on assertion failure.
/// </summary>
public static DebuggerLaunchMode LaunchDebuggerOnAssertionFailure { get; set; }
}
115 changes: 115 additions & 0 deletions src/TestFramework/TestFramework/Internal/CIEnvironmentDetector.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

namespace Microsoft.VisualStudio.TestTools.UnitTesting;

/// <summary>
/// Provides CI environment detection capabilities.
/// </summary>
/// <remarks>
/// Based on https://learn.microsoft.com/dotnet/core/tools/telemetry#continuous-integration-detection
/// and https://github.com/dotnet/sdk/blob/main/src/Cli/dotnet/Telemetry/CIEnvironmentDetectorForTelemetry.cs.
/// </remarks>
internal sealed class CIEnvironmentDetector
{
/// <summary>
/// Gets the default instance that uses the real environment.
/// </summary>
public static CIEnvironmentDetector Instance { get; } = new(EnvironmentWrapper.Instance);

private readonly IEnvironment _environment;

// Systems that provide boolean values only, so we can simply parse and check for true
private static readonly string[] BooleanVariables =
[
// Azure Pipelines - https://docs.microsoft.com/azure/devops/pipelines/build/variables#system-variables-devops-services
"TF_BUILD",

// GitHub Actions - https://docs.github.com/en/actions/learn-github-actions/environment-variables#default-environment-variables
"GITHUB_ACTIONS",

// AppVeyor - https://www.appveyor.com/docs/environment-variables/
"APPVEYOR",

// A general-use flag - Many of the major players support this: AzDo, GitHub, GitLab, AppVeyor, Travis CI, CircleCI.
"CI",

// Travis CI - https://docs.travis-ci.com/user/environment-variables/#default-environment-variables
"TRAVIS",

// CircleCI - https://circleci.com/docs/2.0/env-vars/#built-in-environment-variables
"CIRCLECI",
];

// Systems where every variable must be present and not-null before returning true
private static readonly string[][] AllNotNullVariables =
[
// AWS CodeBuild - https://docs.aws.amazon.com/codebuild/latest/userguide/build-env-ref-env-vars.html
["CODEBUILD_BUILD_ID", "AWS_REGION"],

// Jenkins - https://github.com/jenkinsci/jenkins/blob/master/core/src/main/resources/jenkins/model/CoreEnvironmentContributor/buildEnv.groovy
["BUILD_ID", "BUILD_URL"],

// Google Cloud Build - https://cloud.google.com/build/docs/configuring-builds/substitute-variable-values#using_default_substitutions
["BUILD_ID", "PROJECT_ID"],
];

// Systems where the variable must be present and not-null
private static readonly string[] IfNonNullVariables =
[
// TeamCity - https://www.jetbrains.com/help/teamcity/predefined-build-parameters.html#Predefined+Server+Build+Parameters
"TEAMCITY_VERSION",

// JetBrains Space - https://www.jetbrains.com/help/space/automation-environment-variables.html#general
"JB_SPACE_API_URL",
];

/// <summary>
/// Initializes a new instance of the <see cref="CIEnvironmentDetector"/> class.
/// </summary>
/// <param name="environment">The environment abstraction to use for reading environment variables.</param>
internal /* for testing purposes */ CIEnvironmentDetector(IEnvironment environment) => _environment = environment;

/// <summary>
/// Detects if the current environment is a CI environment.
/// </summary>
/// <returns><c>true</c> if running in a CI environment; otherwise, <c>false</c>.</returns>
public bool IsCIEnvironment()
{
foreach (string booleanVariable in BooleanVariables)
{
if (bool.TryParse(_environment.GetEnvironmentVariable(booleanVariable), out bool envVar) && envVar)
{
return true;
}
}

foreach (string[] variables in AllNotNullVariables)
{
bool allVariablesPresent = true;
foreach (string variable in variables)
{
if (string.IsNullOrEmpty(_environment.GetEnvironmentVariable(variable)))
{
allVariablesPresent = false;
break;
}
}

if (allVariablesPresent)
{
return true;
}
}

foreach (string variable in IfNonNullVariables)
{
if (!string.IsNullOrEmpty(_environment.GetEnvironmentVariable(variable)))
{
return true;
}
}

return false;
}
}
Loading