diff --git a/src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/ApplicationPublisher.cs b/src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/ApplicationPublisher.cs new file mode 100644 index 00000000000000..64c2387fe1e6d3 --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/ApplicationPublisher.cs @@ -0,0 +1,132 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Extensions.Hosting.IntegrationTesting +{ + public class ApplicationPublisher + { + public string ApplicationPath { get; } + + public ApplicationPublisher(string applicationPath) + { + ApplicationPath = applicationPath; + } + + public static readonly string DotnetCommandName = "dotnet"; + + public virtual Task Publish(DeploymentParameters deploymentParameters, ILogger logger) + { + var publishDirectory = CreateTempDirectory(); + using (logger.BeginScope("dotnet-publish")) + { + if (string.IsNullOrEmpty(deploymentParameters.TargetFramework)) + { + throw new Exception($"A target framework must be specified in the deployment parameters for applications that require publishing before deployment"); + } + + var parameters = $"publish " + + $" --output \"{publishDirectory.FullName}\"" + + $" --framework {deploymentParameters.TargetFramework}" + + $" --configuration {deploymentParameters.Configuration}" + // avoids triggering builds of dependencies of the test app which could cause issues like https://github.com/dotnet/arcade/issues/2941 + + $" --no-dependencies" + + $" /p:TargetArchitecture={deploymentParameters.RuntimeArchitecture}" + + " --no-restore"; + + if (deploymentParameters.ApplicationType == ApplicationType.Standalone) + { + parameters += $" --runtime {GetRuntimeIdentifier(deploymentParameters)}"; + } + else + { + // Workaround for https://github.com/aspnet/websdk/issues/422 + parameters += " -p:UseAppHost=false"; + } + + parameters += $" {deploymentParameters.AdditionalPublishParameters}"; + + var startInfo = new ProcessStartInfo + { + FileName = DotnetCommandName, + Arguments = parameters, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardError = true, + RedirectStandardOutput = true, + WorkingDirectory = deploymentParameters.ApplicationPath, + }; + + ProcessHelpers.AddEnvironmentVariablesToProcess(startInfo, deploymentParameters.PublishEnvironmentVariables, logger); + + var hostProcess = new Process() { StartInfo = startInfo }; + + logger.LogInformation($"Executing command {DotnetCommandName} {parameters}"); + + hostProcess.StartAndCaptureOutAndErrToLogger("dotnet-publish", logger); + + // A timeout is passed to Process.WaitForExit() for two reasons: + // + // 1. When process output is read asynchronously, WaitForExit() without a timeout blocks until child processes + // are killed, which can cause hangs due to MSBuild NodeReuse child processes started by dotnet.exe. + // With a timeout, WaitForExit() returns when the parent process is killed and ignores child processes. + // https://stackoverflow.com/a/37983587/102052 + // + // 2. If "dotnet publish" does hang indefinitely for some reason, tests should fail fast with an error message. + const int timeoutMinutes = 5; + if (hostProcess.WaitForExit(milliseconds: timeoutMinutes * 60 * 1000)) + { + if (hostProcess.ExitCode != 0) + { + var message = $"{DotnetCommandName} publish exited with exit code : {hostProcess.ExitCode}"; + logger.LogError(message); + throw new Exception(message); + } + } + else + { + var message = $"{DotnetCommandName} publish failed to exit after {timeoutMinutes} minutes"; + logger.LogError(message); + throw new Exception(message); + } + + logger.LogInformation($"{DotnetCommandName} publish finished with exit code : {hostProcess.ExitCode}"); + } + + return Task.FromResult(new PublishedApplication(publishDirectory.FullName, logger)); + } + + private static string GetRuntimeIdentifier(DeploymentParameters deploymentParameters) + { + var architecture = deploymentParameters.RuntimeArchitecture; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return "win-" + architecture; + } + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + return "linux-" + architecture; + } + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return "osx-" + architecture; + } + throw new InvalidOperationException("Unrecognized operation system platform"); + } + + protected static DirectoryInfo CreateTempDirectory() + { + var tempPath = Path.GetTempPath() + Guid.NewGuid().ToString("N"); + var target = new DirectoryInfo(tempPath); + target.Create(); + return target; + } + } +} diff --git a/src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/Common/ApplicationType.cs b/src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/Common/ApplicationType.cs new file mode 100644 index 00000000000000..c9bb522d4a8990 --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/Common/ApplicationType.cs @@ -0,0 +1,19 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.Extensions.Hosting.IntegrationTesting +{ + public enum ApplicationType + { + /// + /// Does not target a specific platform. Requires the matching runtime to be installed. + /// + Portable, + + /// + /// All dlls are published with the app for x-copy deploy. Net461 requires this because ASP.NET Core is not in the GAC. + /// + Standalone + } +} diff --git a/src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/Common/DeploymentParameters.cs b/src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/Common/DeploymentParameters.cs new file mode 100644 index 00000000000000..106c13f2317990 --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/Common/DeploymentParameters.cs @@ -0,0 +1,158 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; + +namespace Microsoft.Extensions.Hosting.IntegrationTesting +{ + /// + /// Parameters to control application deployment. + /// + public class DeploymentParameters + { + public DeploymentParameters() + { + var configAttribute = Assembly.GetCallingAssembly().GetCustomAttribute(); + if (configAttribute != null && !string.IsNullOrEmpty(configAttribute.Configuration)) + { + Configuration = configAttribute.Configuration; + } + } + + public DeploymentParameters(TestVariant variant) + { + var configAttribute = Assembly.GetCallingAssembly().GetCustomAttribute(); + if (configAttribute != null && !string.IsNullOrEmpty(configAttribute.Configuration)) + { + Configuration = configAttribute.Configuration; + } + + TargetFramework = variant.Tfm; + ApplicationType = variant.ApplicationType; + RuntimeArchitecture = variant.Architecture; + } + + /// + /// Creates an instance of . + /// + /// Source code location of the target location to be deployed. + /// Flavor of the clr to run against. + /// Architecture of the runtime to be used. + public DeploymentParameters( + string applicationPath, + RuntimeFlavor runtimeFlavor, + RuntimeArchitecture runtimeArchitecture) + { + if (string.IsNullOrEmpty(applicationPath)) + { + throw new ArgumentException("Value cannot be null.", nameof(applicationPath)); + } + + if (!Directory.Exists(applicationPath)) + { + throw new DirectoryNotFoundException(string.Format("Application path {0} does not exist.", applicationPath)); + } + + ApplicationPath = applicationPath; + ApplicationName = new DirectoryInfo(ApplicationPath).Name; + RuntimeFlavor = runtimeFlavor; + + var configAttribute = Assembly.GetCallingAssembly().GetCustomAttribute(); + if (configAttribute != null && !string.IsNullOrEmpty(configAttribute.Configuration)) + { + Configuration = configAttribute.Configuration; + } + } + + public DeploymentParameters(DeploymentParameters parameters) + { + foreach (var propertyInfo in typeof(DeploymentParameters).GetProperties()) + { + if (propertyInfo.CanWrite) + { + propertyInfo.SetValue(this, propertyInfo.GetValue(parameters)); + } + } + + foreach (var kvp in parameters.EnvironmentVariables) + { + EnvironmentVariables.Add(kvp); + } + + foreach (var kvp in parameters.PublishEnvironmentVariables) + { + PublishEnvironmentVariables.Add(kvp); + } + } + + public ApplicationPublisher ApplicationPublisher { get; set; } + + public RuntimeFlavor RuntimeFlavor { get; set; } + + public RuntimeArchitecture RuntimeArchitecture { get; set; } = RuntimeArchitecture.x64; + + public string EnvironmentName { get; set; } + + public string ApplicationPath { get; set; } + + /// + /// Gets or sets the name of the application. This is used to execute the application when deployed. + /// Defaults to the file name of . + /// + public string ApplicationName { get; set; } + + public string TargetFramework { get; set; } + + /// + /// Configuration under which to build (ex: Release or Debug) + /// + public string Configuration { get; set; } = "Debug"; + + /// + /// Space separated command line arguments to be passed to dotnet-publish + /// + public string AdditionalPublishParameters { get; set; } + + /// + /// To publish the application before deployment. + /// + public bool PublishApplicationBeforeDeployment { get; set; } + + public bool PreservePublishedApplicationForDebugging { get; set; } = false; + + public bool StatusMessagesEnabled { get; set; } = true; + + public ApplicationType ApplicationType { get; set; } + + public string PublishedApplicationRootPath { get; set; } + + /// + /// Environment variables to be set before starting the host. + /// Not applicable for IIS Scenarios. + /// + public IDictionary EnvironmentVariables { get; } = new Dictionary(); + + /// + /// Environment variables used when invoking dotnet publish. + /// + public IDictionary PublishEnvironmentVariables { get; } = new Dictionary(); + + /// + /// For any application level cleanup to be invoked after performing host cleanup. + /// + public Action UserAdditionalCleanup { get; set; } + + public override string ToString() + { + return string.Format( + "[Variation] :: Runtime={0}, Arch={1}, Publish={2}", + RuntimeFlavor, + RuntimeArchitecture, + PublishApplicationBeforeDeployment); + } + } +} diff --git a/src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/Common/DeploymentResult.cs b/src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/Common/DeploymentResult.cs new file mode 100644 index 00000000000000..9bcf45d433d886 --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/Common/DeploymentResult.cs @@ -0,0 +1,48 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Net.Http; +using System.Threading; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Extensions.Hosting.IntegrationTesting +{ + /// + /// Result of a deployment. + /// + public class DeploymentResult + { + private readonly ILoggerFactory _loggerFactory; + + /// + /// The folder where the application is hosted. This path can be different from the + /// original application source location if published before deployment. + /// + public string ContentRoot { get; } + + /// + /// Original deployment parameters used for this deployment. + /// + public DeploymentParameters DeploymentParameters { get; } + + /// + /// Triggered when the host process dies or pulled down. + /// + public CancellationToken HostShutdownToken { get; } + + public DeploymentResult(ILoggerFactory loggerFactory, DeploymentParameters deploymentParameters) + : this(loggerFactory, deploymentParameters: deploymentParameters, contentRoot: string.Empty, hostShutdownToken: CancellationToken.None) + { } + + public DeploymentResult(ILoggerFactory loggerFactory, DeploymentParameters deploymentParameters, string contentRoot, CancellationToken hostShutdownToken) + { + _loggerFactory = loggerFactory; + + ContentRoot = contentRoot; + DeploymentParameters = deploymentParameters; + HostShutdownToken = hostShutdownToken; + } + } +} diff --git a/src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/Common/DotNetCommands.cs b/src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/Common/DotNetCommands.cs new file mode 100644 index 00000000000000..8afaceddf2f620 --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/Common/DotNetCommands.cs @@ -0,0 +1,78 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.IO; +using System.Runtime.InteropServices; + +namespace Microsoft.Extensions.Hosting.IntegrationTesting +{ + public static class DotNetCommands + { + private const string _dotnetFolderName = ".dotnet"; + + internal static string DotNetHome { get; } = GetDotNetHome(); + + // Compare to https://github.com/aspnet/BuildTools/blob/314c98e4533217a841ff9767bb38e144eb6c93e4/tools/KoreBuild.Console/Commands/CommandContext.cs#L76 + public static string GetDotNetHome() + { + var dotnetHome = Environment.GetEnvironmentVariable("DOTNET_HOME"); + var dotnetRoot = Environment.GetEnvironmentVariable("DOTNET_ROOT"); + var userProfile = Environment.GetEnvironmentVariable("USERPROFILE"); + var home = Environment.GetEnvironmentVariable("HOME"); + + var result = Path.Combine(Directory.GetCurrentDirectory(), _dotnetFolderName); + if (!string.IsNullOrEmpty(dotnetHome)) + { + result = dotnetHome; + } + else if (!string.IsNullOrEmpty(dotnetRoot)) + { + // DOTNET_ROOT has x64 appended to the path, which we append again in GetDotNetInstallDir + result = dotnetRoot.Substring(0, dotnetRoot.Length - 3); + } + else if (!string.IsNullOrEmpty(userProfile)) + { + result = Path.Combine(userProfile, _dotnetFolderName); + } + else if (!string.IsNullOrEmpty(home)) + { + result = home; + } + + return result; + } + + public static string GetDotNetInstallDir(RuntimeArchitecture arch) + { + var dotnetDir = DotNetHome; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + dotnetDir = Path.Combine(dotnetDir, arch.ToString()); + } + + return dotnetDir; + } + + public static string GetDotNetExecutable(RuntimeArchitecture arch) + { + var dotnetDir = GetDotNetInstallDir(arch); + + var dotnetFile = "dotnet"; + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + dotnetFile += ".exe"; + } + + return Path.Combine(dotnetDir, dotnetFile); + } + + public static bool IsRunningX86OnX64(RuntimeArchitecture arch) + { + return (RuntimeInformation.OSArchitecture == Architecture.X64 || RuntimeInformation.OSArchitecture == Architecture.Arm64) + && arch == RuntimeArchitecture.x86; + } + } +} diff --git a/src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/Common/LoggingHandler.cs b/src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/Common/LoggingHandler.cs new file mode 100644 index 00000000000000..b587b6b8d85046 --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/Common/LoggingHandler.cs @@ -0,0 +1,38 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Extensions.Hosting.IntegrationTesting +{ + internal class LoggingHandler : DelegatingHandler + { + private ILogger _logger; + + public LoggingHandler(ILoggerFactory loggerFactory, HttpMessageHandler innerHandler) : base(innerHandler) + { + _logger = loggerFactory.CreateLogger(); + } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + _logger.LogDebug("Sending {method} {url}", request.Method, request.RequestUri); + try + { + var response = await base.SendAsync(request, cancellationToken); + _logger.LogDebug("Received {statusCode} {reasonPhrase} {url}", response.StatusCode, response.ReasonPhrase, request.RequestUri); + return response; + } + catch (Exception ex) + { + _logger.LogError(0, ex, "Exception while sending '{method} {url}' : {exception}", request.Method, request.RequestUri, ex); + throw; + } + } + } +} diff --git a/src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/Common/ProcessLoggingExtensions.cs b/src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/Common/ProcessLoggingExtensions.cs new file mode 100644 index 00000000000000..c4f5807fed7105 --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/Common/ProcessLoggingExtensions.cs @@ -0,0 +1,35 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.Extensions.Logging; + +namespace System.Diagnostics +{ + public static class ProcessLoggingExtensions + { + public static void StartAndCaptureOutAndErrToLogger(this Process process, string prefix, ILogger logger) + { + process.EnableRaisingEvents = true; + process.OutputDataReceived += (_, dataArgs) => + { + if (!string.IsNullOrEmpty(dataArgs.Data)) + { + logger.LogInformation($"{prefix} stdout: {{line}}", dataArgs.Data); + } + }; + + process.ErrorDataReceived += (_, dataArgs) => + { + if (!string.IsNullOrEmpty(dataArgs.Data)) + { + logger.LogWarning($"{prefix} stderr: {{line}}", dataArgs.Data); + } + }; + + process.Start(); + process.BeginErrorReadLine(); + process.BeginOutputReadLine(); + } + } +} diff --git a/src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/Common/RetryHelper.cs b/src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/Common/RetryHelper.cs new file mode 100644 index 00000000000000..622e1450ff8da5 --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/Common/RetryHelper.cs @@ -0,0 +1,38 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Extensions.Hosting.IntegrationTesting +{ + public class RetryHelper + { + public static void RetryOperation( + Action retryBlock, + Action exceptionBlock, + int retryCount = 3, + int retryDelayMilliseconds = 0) + { + for (var retry = 0; retry < retryCount; ++retry) + { + try + { + retryBlock(); + break; + } + catch (Exception exception) + { + exceptionBlock(exception); + } + + Thread.Sleep(retryDelayMilliseconds); + } + } + } +} diff --git a/src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/Common/RuntimeArchitecture.cs b/src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/Common/RuntimeArchitecture.cs new file mode 100644 index 00000000000000..7cfcc48ba1baa4 --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/Common/RuntimeArchitecture.cs @@ -0,0 +1,12 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.Extensions.Hosting.IntegrationTesting +{ + public enum RuntimeArchitecture + { + x64, + x86 + } +} diff --git a/src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/Common/RuntimeFlavor.cs b/src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/Common/RuntimeFlavor.cs new file mode 100644 index 00000000000000..998cfc51e7066a --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/Common/RuntimeFlavor.cs @@ -0,0 +1,13 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.Extensions.Hosting.IntegrationTesting +{ + public enum RuntimeFlavor + { + None, + CoreClr, + Clr + } +} diff --git a/src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/Common/Tfm.cs b/src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/Common/Tfm.cs new file mode 100644 index 00000000000000..a52d1424907e43 --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/Common/Tfm.cs @@ -0,0 +1,24 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; + +namespace Microsoft.Extensions.Hosting.IntegrationTesting +{ + public static class Tfm + { + public const string Net461 = "net461"; + public const string NetCoreApp20 = "netcoreapp2.0"; + public const string NetCoreApp21 = "netcoreapp2.1"; + public const string NetCoreApp22 = "netcoreapp2.2"; + public const string NetCoreApp30 = "netcoreapp3.0"; + public const string NetCoreApp31 = "netcoreapp3.1"; + public const string NetCoreApp50 = "netcoreapp5.0"; + + public static bool Matches(string tfm1, string tfm2) + { + return string.Equals(tfm1, tfm2, StringComparison.OrdinalIgnoreCase); + } + } +} diff --git a/src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/Deployers/ApplicationDeployer.cs b/src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/Deployers/ApplicationDeployer.cs new file mode 100644 index 00000000000000..b24741011bdcdf --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/Deployers/ApplicationDeployer.cs @@ -0,0 +1,196 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Internal; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Extensions.Hosting.IntegrationTesting +{ + /// + /// Abstract base class of all deployers with implementation of some of the common helpers. + /// + public abstract class ApplicationDeployer : IDisposable + { + public static readonly string DotnetCommandName = "dotnet"; + + private readonly Stopwatch _stopwatch = new Stopwatch(); + + private PublishedApplication _publishedApplication; + + public ApplicationDeployer(DeploymentParameters deploymentParameters, ILoggerFactory loggerFactory) + { + DeploymentParameters = deploymentParameters; + LoggerFactory = loggerFactory; + Logger = LoggerFactory.CreateLogger(GetType().FullName); + + ValidateParameters(); + } + + private void ValidateParameters() + { + if (DeploymentParameters.RuntimeFlavor == RuntimeFlavor.None && !string.IsNullOrEmpty(DeploymentParameters.TargetFramework)) + { + DeploymentParameters.RuntimeFlavor = GetRuntimeFlavor(DeploymentParameters.TargetFramework); + } + + if (DeploymentParameters.ApplicationPublisher == null) + { + if (string.IsNullOrEmpty(DeploymentParameters.ApplicationPath)) + { + throw new ArgumentException("ApplicationPath cannot be null."); + } + + if (!Directory.Exists(DeploymentParameters.ApplicationPath)) + { + throw new DirectoryNotFoundException(string.Format("Application path {0} does not exist.", DeploymentParameters.ApplicationPath)); + } + + if (string.IsNullOrEmpty(DeploymentParameters.ApplicationName)) + { + DeploymentParameters.ApplicationName = new DirectoryInfo(DeploymentParameters.ApplicationPath).Name; + } + } + } + + private RuntimeFlavor GetRuntimeFlavor(string tfm) + { + if (Tfm.Matches(Tfm.Net461, tfm)) + { + return RuntimeFlavor.Clr; + } + return RuntimeFlavor.CoreClr; + } + + protected DeploymentParameters DeploymentParameters { get; } + + protected ILoggerFactory LoggerFactory { get; } + + protected ILogger Logger { get; } + + public abstract Task DeployAsync(); + + protected void DotnetPublish(string publishRoot = null) + { + var publisher = DeploymentParameters.ApplicationPublisher ?? new ApplicationPublisher(DeploymentParameters.ApplicationPath); + _publishedApplication = publisher.Publish(DeploymentParameters, Logger).GetAwaiter().GetResult(); + DeploymentParameters.PublishedApplicationRootPath = _publishedApplication.Path; + } + + protected void CleanPublishedOutput() + { + using (Logger.BeginScope("CleanPublishedOutput")) + { + if (DeploymentParameters.PreservePublishedApplicationForDebugging) + { + Logger.LogWarning( + "Skipping deleting the locally published folder as property " + + $"'{nameof(DeploymentParameters.PreservePublishedApplicationForDebugging)}' is set to 'true'."); + } + else + { + _publishedApplication?.Dispose(); + } + } + } + + protected string GetDotNetExeForArchitecture() + { + var executableName = DotnetCommandName; + // We expect x64 dotnet.exe to be on the path but we have to go searching for the x86 version. + if (DotNetCommands.IsRunningX86OnX64(DeploymentParameters.RuntimeArchitecture)) + { + executableName = DotNetCommands.GetDotNetExecutable(DeploymentParameters.RuntimeArchitecture); + if (!File.Exists(executableName)) + { + throw new Exception($"Unable to find '{executableName}'.'"); + } + } + + return executableName; + } + + protected void ShutDownIfAnyHostProcess(Process hostProcess) + { + if (hostProcess != null && !hostProcess.HasExited) + { + Logger.LogInformation("Attempting to cancel process {0}", hostProcess.Id); + + // Shutdown the host process. + hostProcess.KillTree(); + if (!hostProcess.HasExited) + { + Logger.LogWarning("Unable to terminate the host process with process Id '{processId}", hostProcess.Id); + } + else + { + Logger.LogInformation("Successfully terminated host process with process Id '{processId}'", hostProcess.Id); + } + } + else + { + Logger.LogWarning("Host process already exited or never started successfully."); + } + } + + protected void AddEnvironmentVariablesToProcess(ProcessStartInfo startInfo, IDictionary environmentVariables) + { + var environment = startInfo.Environment; + ProcessHelpers.SetEnvironmentVariable(environment, "ASPNETCORE_ENVIRONMENT", DeploymentParameters.EnvironmentName, Logger); + ProcessHelpers.AddEnvironmentVariablesToProcess(startInfo, environmentVariables, Logger); + } + + protected void InvokeUserApplicationCleanup() + { + using (Logger.BeginScope("UserAdditionalCleanup")) + { + if (DeploymentParameters.UserAdditionalCleanup != null) + { + // User cleanup. + try + { + DeploymentParameters.UserAdditionalCleanup(DeploymentParameters); + } + catch (Exception exception) + { + Logger.LogWarning("User cleanup code failed with exception : {exception}", exception.Message); + } + } + } + } + + protected void TriggerHostShutdown(CancellationTokenSource hostShutdownSource) + { + Logger.LogInformation("Host process shutting down."); + try + { + hostShutdownSource.Cancel(); + } + catch (Exception) + { + // Suppress errors. + } + } + + protected void StartTimer() + { + Logger.LogInformation($"Deploying {DeploymentParameters.ToString()}"); + _stopwatch.Start(); + } + + protected void StopTimer() + { + _stopwatch.Stop(); + Logger.LogInformation("[Time]: Total time taken for this test variation '{t}' seconds", _stopwatch.Elapsed.TotalSeconds); + } + + public abstract void Dispose(); + } +} diff --git a/src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/Deployers/ApplicationDeployerFactory.cs b/src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/Deployers/ApplicationDeployerFactory.cs new file mode 100644 index 00000000000000..563c8d0be366ff --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/Deployers/ApplicationDeployerFactory.cs @@ -0,0 +1,36 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Extensions.Hosting.IntegrationTesting +{ + /// + /// Factory to create an appropriate deployer based on . + /// + public class ApplicationDeployerFactory + { + /// + /// Creates a deployer instance based on settings in . + /// + /// The parameters to set on the deployer. + /// The factory used to create loggers. + /// The that was created. + public static ApplicationDeployer Create(DeploymentParameters deploymentParameters, ILoggerFactory loggerFactory) + { + if (deploymentParameters == null) + { + throw new ArgumentNullException(nameof(deploymentParameters)); + } + + if (loggerFactory == null) + { + throw new ArgumentNullException(nameof(loggerFactory)); + } + + return new SelfHostDeployer(deploymentParameters, loggerFactory); + } + } +} diff --git a/src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/Deployers/SelfHostDeployer.cs b/src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/Deployers/SelfHostDeployer.cs new file mode 100644 index 00000000000000..19f9c63a23ba85 --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/Deployers/SelfHostDeployer.cs @@ -0,0 +1,193 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Testing; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Extensions.Hosting.IntegrationTesting +{ + /// + /// Deployer for WebListener and Kestrel. + /// + public class SelfHostDeployer : ApplicationDeployer + { + private const string ApplicationStartedMessage = "Application started. Press Ctrl+C to shut down."; + + public Process HostProcess { get; private set; } + + public SelfHostDeployer(DeploymentParameters deploymentParameters, ILoggerFactory loggerFactory) + : base(deploymentParameters, loggerFactory) + { + } + + public override async Task DeployAsync() + { + using (Logger.BeginScope("SelfHost.Deploy")) + { + // Start timer + StartTimer(); + + if (DeploymentParameters.RuntimeFlavor == RuntimeFlavor.Clr + && DeploymentParameters.RuntimeArchitecture == RuntimeArchitecture.x86) + { + // Publish is required to rebuild for the right bitness + DeploymentParameters.PublishApplicationBeforeDeployment = true; + } + + if (DeploymentParameters.RuntimeFlavor == RuntimeFlavor.CoreClr + && DeploymentParameters.ApplicationType == ApplicationType.Standalone) + { + // Publish is required to get the correct files in the output directory + DeploymentParameters.PublishApplicationBeforeDeployment = true; + } + + if (DeploymentParameters.PublishApplicationBeforeDeployment) + { + DotnetPublish(); + } + + // Launch the host process. + var hostExitToken = await StartSelfHostAsync(); + + Logger.LogInformation("Application ready"); + + return new DeploymentResult( + LoggerFactory, + DeploymentParameters, + contentRoot: DeploymentParameters.PublishApplicationBeforeDeployment ? DeploymentParameters.PublishedApplicationRootPath : DeploymentParameters.ApplicationPath, + hostShutdownToken: hostExitToken); + } + } + + protected async Task StartSelfHostAsync() + { + using (Logger.BeginScope("StartSelfHost")) + { + var executableName = string.Empty; + var executableArgs = string.Empty; + var workingDirectory = string.Empty; + var executableExtension = DeploymentParameters.ApplicationType == ApplicationType.Portable ? ".dll" + : (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? ".exe" : ""); + + if (DeploymentParameters.PublishApplicationBeforeDeployment) + { + workingDirectory = DeploymentParameters.PublishedApplicationRootPath; + } + else + { + // Core+Standalone always publishes. This must be Clr+Standalone or Core+Portable. + // Run from the pre-built bin/{config}/{tfm} directory. + var targetFramework = DeploymentParameters.TargetFramework + ?? (DeploymentParameters.RuntimeFlavor == RuntimeFlavor.Clr ? Tfm.Net461 : Tfm.NetCoreApp22); + workingDirectory = Path.Combine(DeploymentParameters.ApplicationPath, "bin", DeploymentParameters.Configuration, targetFramework); + // CurrentDirectory will point to bin/{config}/{tfm}, but the config and static files aren't copied, point to the app base instead. + DeploymentParameters.EnvironmentVariables["DOTNET_CONTENTROOT"] = DeploymentParameters.ApplicationPath; + } + + var executable = Path.Combine(workingDirectory, DeploymentParameters.ApplicationName + executableExtension); + + if (DeploymentParameters.RuntimeFlavor == RuntimeFlavor.CoreClr && DeploymentParameters.ApplicationType == ApplicationType.Portable) + { + executableName = GetDotNetExeForArchitecture(); + executableArgs = executable; + } + else + { + executableName = executable; + } + + Logger.LogInformation($"Executing {executableName} {executableArgs}"); + + var startInfo = new ProcessStartInfo + { + FileName = executableName, + Arguments = executableArgs, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardError = true, + RedirectStandardOutput = true, + // Trying a work around for https://github.com/aspnet/Hosting/issues/140. + RedirectStandardInput = true, + WorkingDirectory = workingDirectory + }; + + AddEnvironmentVariablesToProcess(startInfo, DeploymentParameters.EnvironmentVariables); + + var started = new TaskCompletionSource(); + + HostProcess = new Process() { StartInfo = startInfo }; + HostProcess.EnableRaisingEvents = true; + HostProcess.OutputDataReceived += (sender, dataArgs) => + { + if (string.Equals(dataArgs.Data, ApplicationStartedMessage)) + { + started.TrySetResult(null); + } + }; + var hostExitTokenSource = new CancellationTokenSource(); + HostProcess.Exited += (sender, e) => + { + Logger.LogInformation("host process ID {pid} shut down", HostProcess.Id); + + // If TrySetResult was called above, this will just silently fail to set the new state, which is what we want + started.TrySetException(new Exception($"Command exited unexpectedly with exit code: {HostProcess.ExitCode}")); + + TriggerHostShutdown(hostExitTokenSource); + }; + + try + { + HostProcess.StartAndCaptureOutAndErrToLogger(executableName, Logger); + } + catch (Exception ex) + { + Logger.LogError("Error occurred while starting the process. Exception: {exception}", ex.ToString()); + } + + if (HostProcess.HasExited) + { + Logger.LogError("Host process {processName} {pid} exited with code {exitCode} or failed to start.", startInfo.FileName, HostProcess.Id, HostProcess.ExitCode); + throw new Exception("Failed to start host"); + } + + Logger.LogInformation("Started {fileName}. Process Id : {processId}", startInfo.FileName, HostProcess.Id); + + // Host may not write startup messages, in which case assume it started + if (DeploymentParameters.StatusMessagesEnabled) + { + // The timeout here is large, because we don't know how long the test could need + // We cover a lot of error cases above, but I want to make sure we eventually give up and don't hang the build + // just in case we missed one -anurse + await started.Task.TimeoutAfter(TimeSpan.FromMinutes(10)); + } + + return hostExitTokenSource.Token; + } + } + + public override void Dispose() + { + using (Logger.BeginScope("SelfHost.Dispose")) + { + ShutDownIfAnyHostProcess(HostProcess); + + if (DeploymentParameters.PublishApplicationBeforeDeployment) + { + CleanPublishedOutput(); + } + + InvokeUserApplicationCleanup(); + + StopTimer(); + } + } + } +} diff --git a/src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/Microsoft.Extensions.Hosting.IntegrationTesting.csproj b/src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/Microsoft.Extensions.Hosting.IntegrationTesting.csproj new file mode 100644 index 00000000000000..cc2f6240318326 --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/Microsoft.Extensions.Hosting.IntegrationTesting.csproj @@ -0,0 +1,26 @@ + + + + .NET Core Extensions helpers to deploy applications to for testing. + $(DefaultNetCoreTargetFramework);net472 + false + $(NoWarn);CS1591 + true + testing + true + + false + true + true + + + + + + + + + + + + diff --git a/src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/ProcessExtensions.cs b/src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/ProcessExtensions.cs new file mode 100644 index 00000000000000..bdccff910a96ea --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/ProcessExtensions.cs @@ -0,0 +1,107 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; + +namespace Microsoft.Extensions.Internal +{ + internal static class ProcessExtensions + { + private static readonly bool _isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + private static readonly TimeSpan _defaultTimeout = TimeSpan.FromSeconds(30); + + public static void KillTree(this Process process) => process.KillTree(_defaultTimeout); + + public static void KillTree(this Process process, TimeSpan timeout) + { + var pid = process.Id; + if (_isWindows) + { + RunProcessAndWaitForExit( + "taskkill", + $"/T /F /PID {pid}", + timeout, + out var _); + } + else + { + var children = new HashSet(); + GetAllChildIdsUnix(pid, children, timeout); + foreach (var childId in children) + { + KillProcessUnix(childId, timeout); + } + KillProcessUnix(pid, timeout); + } + } + + private static void GetAllChildIdsUnix(int parentId, ISet children, TimeSpan timeout) + { + RunProcessAndWaitForExit( + "pgrep", + $"-P {parentId}", + timeout, + out var stdout); + + if (!string.IsNullOrEmpty(stdout)) + { + using (var reader = new StringReader(stdout)) + { + while (true) + { + var text = reader.ReadLine(); + if (text == null) + { + return; + } + + if (int.TryParse(text, out var id)) + { + children.Add(id); + // Recursively get the children + GetAllChildIdsUnix(id, children, timeout); + } + } + } + } + } + + private static void KillProcessUnix(int processId, TimeSpan timeout) + { + RunProcessAndWaitForExit( + "kill", + $"-TERM {processId}", + timeout, + out var stdout); + } + + private static void RunProcessAndWaitForExit(string fileName, string arguments, TimeSpan timeout, out string stdout) + { + var startInfo = new ProcessStartInfo + { + FileName = fileName, + Arguments = arguments, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + + var process = Process.Start(startInfo); + + stdout = null; + if (process.WaitForExit((int)timeout.TotalMilliseconds)) + { + stdout = process.StandardOutput.ReadToEnd(); + } + else + { + process.Kill(); + } + } + } +} diff --git a/src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/ProcessHelpers.cs b/src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/ProcessHelpers.cs new file mode 100644 index 00000000000000..65c21f9a415deb --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/ProcessHelpers.cs @@ -0,0 +1,37 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using System.Diagnostics; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Extensions.Hosting.IntegrationTesting +{ + internal class ProcessHelpers + { + public static void AddEnvironmentVariablesToProcess(ProcessStartInfo startInfo, IDictionary environmentVariables, ILogger logger) + { + var environment = startInfo.Environment; + + foreach (var environmentVariable in environmentVariables) + { + SetEnvironmentVariable(environment, environmentVariable.Key, environmentVariable.Value, logger); + } + } + + public static void SetEnvironmentVariable(IDictionary environment, string name, string value, ILogger logger) + { + if (value == null) + { + logger.LogInformation("Removing environment variable {name}", name); + environment.Remove(name); + } + else + { + logger.LogInformation("SET {name}={value}", name, value); + environment[name] = value; + } + } + } +} \ No newline at end of file diff --git a/src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/PublishedApplication.cs b/src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/PublishedApplication.cs new file mode 100644 index 00000000000000..a972e2cea7efc2 --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/PublishedApplication.cs @@ -0,0 +1,32 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.IO; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Extensions.Hosting.IntegrationTesting +{ + public class PublishedApplication: IDisposable + { + private readonly ILogger _logger; + + public string Path { get; } + + public PublishedApplication(string path, ILogger logger) + { + _logger = logger; + Path = path; + } + + public void Dispose() + { + RetryHelper.RetryOperation( + () => Directory.Delete(Path, true), + e => _logger.LogWarning($"Failed to delete directory : {e.Message}"), + retryCount: 3, + retryDelayMilliseconds: 100); + } + } +} \ No newline at end of file diff --git a/src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/TestMatrix.cs b/src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/TestMatrix.cs new file mode 100644 index 00000000000000..c5833e615c699a --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/TestMatrix.cs @@ -0,0 +1,203 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.InteropServices; + +namespace Microsoft.Extensions.Hosting.IntegrationTesting +{ + public class TestMatrix : IEnumerable + { + public IList Tfms { get; set; } = new List(); + public IList ApplicationTypes { get; set; } = new List(); + public IList Architectures { get; set; } = new List(); + private IList, string>> Skips { get; } = new List, string>>(); + + public static TestMatrix Create() + { + return new TestMatrix(); + } + + public TestMatrix WithTfms(params string[] tfms) + { + Tfms = tfms; + return this; + } + + public TestMatrix WithApplicationTypes(params ApplicationType[] types) + { + ApplicationTypes = types; + return this; + } + + public TestMatrix WithAllApplicationTypes() + { + ApplicationTypes.Add(ApplicationType.Portable); + ApplicationTypes.Add(ApplicationType.Standalone); + return this; + } + public TestMatrix WithArchitectures(params RuntimeArchitecture[] archs) + { + Architectures = archs; + return this; + } + + public TestMatrix WithAllArchitectures() + { + Architectures.Add(RuntimeArchitecture.x64); + Architectures.Add(RuntimeArchitecture.x86); + return this; + } + + public TestMatrix Skip(string message, Func check) + { + Skips.Add(new Tuple, string>(check, message)); + return this; + } + + private IEnumerable Build() + { + // TFMs. + if (!Tfms.Any()) + { + throw new ArgumentException("No TFMs were specified."); + } + + ResolveDefaultArchitecture(); + + if (!ApplicationTypes.Any()) + { + ApplicationTypes.Add(ApplicationType.Portable); + } + + var variants = new List(); + VaryByTfm(variants); + + CheckForSkips(variants); + + return variants; + } + + private void ResolveDefaultArchitecture() + { + if (!Architectures.Any()) + { + switch (RuntimeInformation.OSArchitecture) + { + case Architecture.X86: + Architectures.Add(RuntimeArchitecture.x86); + break; + case Architecture.X64: + Architectures.Add(RuntimeArchitecture.x64); + break; + default: + throw new ArgumentException(RuntimeInformation.OSArchitecture.ToString()); + } + } + } + + private void VaryByTfm(List variants) + { + foreach (var tfm in Tfms) + { + var skipTfm = SkipIfTfmIsNotSupportedOnThisOS(tfm); + + VaryByApplicationType(variants, tfm, skipTfm); + } + } + + private static string SkipIfTfmIsNotSupportedOnThisOS(string tfm) + { + if (Tfm.Matches(Tfm.Net461, tfm) && !RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return "This TFM is not supported on this operating system."; + } + + return null; + } + + private void VaryByApplicationType(List variants, string tfm, string skip) + { + foreach (var t in ApplicationTypes) + { + var type = t; + if (Tfm.Matches(Tfm.Net461, tfm) && type == ApplicationType.Portable) + { + if (ApplicationTypes.Count == 1) + { + // Override the default + type = ApplicationType.Standalone; + } + else + { + continue; + } + } + + VaryByArchitecture(variants, tfm, skip, type); + } + } + + private void VaryByArchitecture(List variants, string tfm, string skip, ApplicationType type) + { + foreach (var arch in Architectures) + { + var archSkip = skip ?? SkipIfArchitectureNotSupportedOnCurrentSystem(arch); + + variants.Add(new TestVariant() + { + Tfm = tfm, + ApplicationType = type, + Architecture = arch, + Skip = archSkip, + }); + } + } + + private string SkipIfArchitectureNotSupportedOnCurrentSystem(RuntimeArchitecture arch) + { + if (arch == RuntimeArchitecture.x64) + { + // Can't run x64 on a x86 OS. + return (RuntimeInformation.OSArchitecture == Architecture.Arm || RuntimeInformation.OSArchitecture == Architecture.X86) + ? $"Cannot run {arch} on your current system." : null; + } + + // No x86 runtimes available on MacOS or Linux. + return RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? null : $"No {arch} available for non-Windows systems."; + } + + private void CheckForSkips(List variants) + { + foreach (var variant in variants) + { + foreach (var skipPair in Skips) + { + if (skipPair.Item1(variant)) + { + variant.Skip = skipPair.Item2; + break; + } + } + } + } + + IEnumerator IEnumerable.GetEnumerator() + { + return ((IEnumerable)this).GetEnumerator(); + } + + // This is what Xunit MemberData expects + public IEnumerator GetEnumerator() + { + foreach (var v in Build()) + { + yield return new[] { v }; + } + } + } +} diff --git a/src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/TestVariant.cs b/src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/TestVariant.cs new file mode 100644 index 00000000000000..dc560b31735ad6 --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/TestVariant.cs @@ -0,0 +1,39 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Xunit.Abstractions; + +namespace Microsoft.Extensions.Hosting.IntegrationTesting +{ + public class TestVariant : IXunitSerializable + { + public string Tfm { get; set; } + public ApplicationType ApplicationType { get; set; } + public RuntimeArchitecture Architecture { get; set; } + + public string Skip { get; set; } + + public override string ToString() + { + // For debug and test explorer view + return $"TFM: {Tfm}, Type: {ApplicationType}, Arch: {Architecture}"; + } + + public void Serialize(IXunitSerializationInfo info) + { + info.AddValue(nameof(Skip), Skip, typeof(string)); + info.AddValue(nameof(Tfm), Tfm, typeof(string)); + info.AddValue(nameof(ApplicationType), ApplicationType, typeof(ApplicationType)); + info.AddValue(nameof(Architecture), Architecture, typeof(RuntimeArchitecture)); + } + + public void Deserialize(IXunitSerializationInfo info) + { + Skip = info.GetValue(nameof(Skip)); + Tfm = info.GetValue(nameof(Tfm)); + ApplicationType = info.GetValue(nameof(ApplicationType)); + Architecture = info.GetValue(nameof(Architecture)); + } + } +} diff --git a/src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/xunit/SkipIfEnvironmentVariableNotEnabled.cs b/src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/xunit/SkipIfEnvironmentVariableNotEnabled.cs new file mode 100644 index 00000000000000..60d832ca3a9939 --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/xunit/SkipIfEnvironmentVariableNotEnabled.cs @@ -0,0 +1,42 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using Microsoft.AspNetCore.Testing; + +namespace Microsoft.Extensions.Hosting.IntegrationTesting +{ + /// + /// Skip test if a given environment variable is not enabled. To enable the test, set environment variable + /// to "true" for the test process. + /// + [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] + public class SkipIfEnvironmentVariableNotEnabledAttribute : Attribute, ITestCondition + { + private readonly string _environmentVariableName; + + public SkipIfEnvironmentVariableNotEnabledAttribute(string environmentVariableName) + { + _environmentVariableName = environmentVariableName; + } + + public bool IsMet + { + get + { + return string.Compare(Environment.GetEnvironmentVariable(_environmentVariableName), "true", ignoreCase: true) == 0; + } + } + + public string SkipReason + { + get + { + return $"To run this test, set the environment variable {_environmentVariableName}=\"true\". {AdditionalInfo}"; + } + } + + public string AdditionalInfo { get; set; } + } +} \ No newline at end of file diff --git a/src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/xunit/SkipOn32BitOSAttribute.cs b/src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/xunit/SkipOn32BitOSAttribute.cs new file mode 100644 index 00000000000000..999012d7f591ca --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/xunit/SkipOn32BitOSAttribute.cs @@ -0,0 +1,23 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Runtime.InteropServices; +using Microsoft.AspNetCore.Testing; + +namespace Microsoft.Extensions.Hosting.IntegrationTesting +{ + /// + /// Skips a 64 bit test if the current OS is 32-bit. + /// + [AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] + public class SkipOn32BitOSAttribute : Attribute, ITestCondition + { + public bool IsMet => + RuntimeInformation.OSArchitecture == Architecture.Arm64 + || RuntimeInformation.OSArchitecture == Architecture.X64; + + public string SkipReason => "Skipping the x64 test since Windows is 32-bit"; + } +} \ No newline at end of file