// 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.Configurations;
using Microsoft.Testing.Platform.DynamicExtensions;
using Microsoft.Testing.Platform.Helpers;
using Microsoft.Testing.Platform.Hosts;
using Microsoft.Testing.Platform.Logging;
using Microsoft.Testing.Platform.OutputDevice;
using Microsoft.Testing.Platform.Resources;
using Microsoft.Testing.Platform.Services;
using Microsoft.Testing.Platform.TestHostControllers;
namespace Microsoft.Testing.Platform.Builder;
///
/// Represents a test application.
///
public sealed class TestApplication : ITestApplication
{
private readonly IHost _host;
private static UnhandledExceptionHandler? s_unhandledExceptionHandler;
static TestApplication() =>
// Capture system console soon as possible to avoid any other code from changing it.
// This is important for the console display system to work properly.
_ = new SystemConsole();
internal TestApplication(IHost host) => _host = host;
// This cast looks like incorrect assumption.
// This property is currently accessed in unit tests only.
internal IServiceProvider ServiceProvider => ((CommonHost)_host).ServiceProvider;
///
/// Creates a server mode builder asynchronously.
///
/// The command line arguments.
/// The test application options.
/// The task representing the asynchronous operation.
#if NET8_0_OR_GREATER
[Obsolete("This method is obsolete. Use CreateBuilderAsync instead.", DiagnosticId = "MTP0002", UrlFormat = "https://aka.ms/testingplatform/diagnostics#{0}")]
#else
[Obsolete("This method is obsolete. Use CreateBuilderAsync instead.")]
#endif
public static Task CreateServerModeBuilderAsync(string[] args, TestApplicationOptions? testApplicationOptions = null)
{
if (args.Contains($"--{PlatformCommandLineProvider.ServerOptionKey}") || args.Contains($"-{PlatformCommandLineProvider.ServerOptionKey}"))
{
// Remove the --server option from the args so that the builder can be created.
args = [.. args.Where(arg => arg.Trim('-') != PlatformCommandLineProvider.ServerOptionKey)];
}
return CreateBuilderAsync([.. args, $"--{PlatformCommandLineProvider.ServerOptionKey}"], testApplicationOptions);
}
///
/// Creates a builder asynchronously.
///
/// The command line arguments.
/// The test application options.
/// The task representing the asynchronous operation.
public static async Task CreateBuilderAsync(string[] args, TestApplicationOptions? testApplicationOptions = null)
{
SystemEnvironment systemEnvironment = new();
// See AB#2304879.
UILanguageOverride.SetCultureSpecifiedByUser(systemEnvironment);
// We get the time to save it in the logs for testcontrollers troubleshooting.
SystemClock systemClock = new();
DateTimeOffset createBuilderStart = systemClock.UtcNow;
string createBuilderEntryTime = createBuilderStart.ToString("HH:mm:ss.fff", CultureInfo.InvariantCulture);
testApplicationOptions ??= new TestApplicationOptions();
SystemConsole systemConsole = new();
SystemProcessHandler systemProcess = new();
AttachDebuggerIfNeeded(systemEnvironment, systemConsole, systemProcess);
// First step is to parse the command line from where we get the second input layer.
// The first one should be the env vars handled autonomously by extensions and part of the test platform.
CommandLineParseResult parseResult = CommandLineParser.Parse(args, systemEnvironment);
if (parseResult.IsOptionSet(PlatformCommandLineProvider.DebugAttachOptionKey))
{
if (OperatingSystem.IsBrowser())
{
throw new PlatformNotSupportedException(PlatformResources.WaitDebuggerAttachNotSupportedInBrowserErrorMessage);
}
if (OperatingSystem.IsWasi())
{
throw new PlatformNotSupportedException(PlatformResources.WaitDebuggerAttachNotSupportedInWasiErrorMessage);
}
WaitForDebuggerToAttach(systemEnvironment, systemConsole, systemProcess);
}
TestHostControllerInfo testHostControllerInfo = new(parseResult);
CurrentTestApplicationModuleInfo testApplicationModuleInfo = new(systemEnvironment, systemProcess, args, [.. parseResult.ExpandedArguments]);
// Create the UnhandledExceptionHandler that will be set inside the TestHostBuilder.
LazyInitializer.EnsureInitialized(ref s_unhandledExceptionHandler, () => new UnhandledExceptionHandler(systemEnvironment, systemConsole, parseResult.IsOptionSet(PlatformCommandLineProvider.TestHostControllerPIDOptionKey)));
ApplicationStateGuard.Ensure(s_unhandledExceptionHandler is not null);
// First task is to setup the logger if enabled and we take the info from the command line or env vars.
ApplicationLoggingState loggingState = CreateFileLoggerIfDiagnosticIsEnabled(parseResult, testApplicationModuleInfo, systemClock, systemEnvironment, new SystemTask(), new SystemConsole());
if (loggingState.FileLoggerProvider is not null)
{
ILogger logger = loggingState.FileLoggerProvider.CreateLogger(typeof(TestApplication).ToString());
s_unhandledExceptionHandler.SetLogger(logger);
await LogInformationAsync(logger, testApplicationModuleInfo, testHostControllerInfo, systemEnvironment, createBuilderEntryTime, loggingState.IsSynchronousWrite, loggingState.LogLevel, args).ConfigureAwait(false);
}
// All checks are fine, create the TestApplication.
TestApplicationBuilder builder = new(loggingState, createBuilderStart, testApplicationOptions, s_unhandledExceptionHandler, args, [.. parseResult.ExpandedArguments]);
// Register dynamically declared extensions as the last thing before handing the builder back. Note this
// still puts them *ahead* of statically registered ones: the caller only invokes
// AddSelfRegisteredExtensions after this method returns. Doing it here rather than in BuildAsync is what
// makes the behaviour identical for the MSBuild-generated entry point and a hand-written Main. See
// docs/RFCs/023-Dynamic-Extension-Loading.md.
SystemFileSystem fileSystem = new();
DynamicExtensionLoader dynamicExtensionLoader = new(
fileSystem,
testApplicationModuleInfo,
new DynamicExtensionAssemblyLoader(fileSystem),
systemConsole,
parseResult,
loggingState.FileLoggerProvider?.CreateLogger(typeof(DynamicExtensionLoader).ToString()));
try
{
await dynamicExtensionLoader.LoadAsync(builder, args).ConfigureAwait(false);
}
catch (Exception)
{
// The failure escapes CreateBuilderAsync, so nothing downstream will ever flush the diagnostic log
// — and its trace of which manifests were read and which extension failed is the whole point of
// having written it. Flush it before the exception reaches the entry point.
//
// Flushing is best-effort: DisposeAsync performs fallible stream work, and letting that failure
// escape would replace the manifest-aware error we are trying to preserve with one about the log
// file. Losing the log is bad; losing the reason the run failed is worse.
if (loggingState.FileLoggerProvider is { } fileLoggerProvider)
{
try
{
await DisposeHelper.DisposeAsync(fileLoggerProvider).ConfigureAwait(false);
}
catch (Exception)
{
// Intentionally empty: there is no sink left to report this to. The file logger is the
// thing that just failed, and standard output cannot be used here because it is a
// protocol channel under --server and holds a single JSON document under
// --list-tests json, so writing would corrupt a machine-readable stream. The original
// failure is rethrown below, which is what the user actually needs.
}
}
throw;
}
return builder;
}
private static async Task LogInformationAsync(
ILogger logger,
CurrentTestApplicationModuleInfo testApplicationModuleInfo,
TestHostControllerInfo testHostControllerInfo,
SystemEnvironment environment,
string createBuilderEntryTime,
bool syncWrite,
LogLevel loggerLevel,
string[] args)
{
// Log useful information
AssemblyInformationalVersionAttribute? version = Assembly.GetExecutingAssembly().GetCustomAttribute();
if (version is not null)
{
await logger.LogInformationAsync($"Version: {version.InformationalVersion}").ConfigureAwait(false);
}
else
{
await logger.LogInformationAsync("Version attribute not found").ConfigureAwait(false);
}
await logger.LogInformationAsync("Logging mode: " + (syncWrite ? "synchronous" : "asynchronous")).ConfigureAwait(false);
await logger.LogInformationAsync($"Logging level: {loggerLevel}").ConfigureAwait(false);
await logger.LogInformationAsync($"CreateBuilderAsync entry time: {createBuilderEntryTime}").ConfigureAwait(false);
await logger.LogInformationAsync($"PID: {environment.ProcessId}").ConfigureAwait(false);
#if NETCOREAPP
string runtimeInformation = $"{RuntimeInformation.RuntimeIdentifier} - {RuntimeInformation.FrameworkDescription}";
#else
string runtimeInformation = $"{RuntimeInformation.ProcessArchitecture} - {RuntimeInformation.FrameworkDescription}";
#endif
await logger.LogInformationAsync($"Runtime information: {runtimeInformation}").ConfigureAwait(false);
#if NETCOREAPP
if (RuntimeFeature.IsDynamicCodeSupported)
{
#pragma warning disable IL3000 // Avoid accessing Assembly file path when publishing as a single file
string? runtimeLocation = typeof(object).Assembly.Location;
#pragma warning restore IL3000 // Avoid accessing Assembly file path when publishing as a single file
if (runtimeLocation is not null)
{
await logger.LogInformationAsync($"Runtime location: {runtimeLocation}").ConfigureAwait(false);
}
else
{
await logger.LogInformationAsync("Runtime location not found.").ConfigureAwait(false);
}
}
#else
#pragma warning disable IL3000 // Avoid accessing Assembly file path when publishing as a single file, this branch run only on .NET Framework
string? runtimeLocation = typeof(object).Assembly?.Location;
#pragma warning restore IL3000 // Avoid accessing Assembly file path when publishing as a single file
if (runtimeLocation is not null)
{
await logger.LogInformationAsync($"Runtime location: {runtimeLocation}").ConfigureAwait(false);
}
else
{
await logger.LogInformationAsync($"Runtime location not found.").ConfigureAwait(false);
}
#endif
bool isDynamicCodeSupported = false;
#if NETCOREAPP
isDynamicCodeSupported = RuntimeFeature.IsDynamicCodeSupported;
#endif
await logger.LogInformationAsync($"IsDynamicCodeSupported: {isDynamicCodeSupported}").ConfigureAwait(false);
string moduleName = testApplicationModuleInfo.GetDisplayName();
await logger.LogInformationAsync($"Test module: {moduleName}").ConfigureAwait(false);
await logger.LogInformationAsync($"Command line arguments: '{CommandLineArgumentsRedactor.Redact(args)}'").ConfigureAwait(false);
StringBuilder machineInfo = new();
#pragma warning disable RS0030 // Do not use banned APIs
machineInfo.AppendLine(CultureInfo.InvariantCulture, $"Machine name: {Environment.MachineName}");
machineInfo.AppendLine(CultureInfo.InvariantCulture, $"OSVersion: {Environment.OSVersion}");
machineInfo.AppendLine(CultureInfo.InvariantCulture, $"ProcessorCount: {Environment.ProcessorCount}");
machineInfo.AppendLine(CultureInfo.InvariantCulture, $"Is64BitOperatingSystem: {Environment.Is64BitOperatingSystem}");
#pragma warning restore RS0030 // Do not use banned APIs
#if NETCOREAPP
machineInfo.AppendLine(CultureInfo.InvariantCulture, $"TotalAvailableMemoryBytes(GB): {GC.GetGCMemoryInfo().TotalAvailableMemoryBytes / 1_000_000_000}");
#endif
await logger.LogDebugAsync($"Machine info:\n{machineInfo}").ConfigureAwait(false);
if (testHostControllerInfo.HasTestHostController)
{
int? testHostControllerPID = testHostControllerInfo.GetTestHostControllerPID();
await LogVariableAsync(EnvironmentVariableConstants.TESTINGPLATFORM_TESTHOSTCONTROLLER_CORRELATIONID).ConfigureAwait(false);
await LogVariableAsync(EnvironmentVariableConstants.TESTINGPLATFORM_TESTHOSTCONTROLLER_PARENTPID).ConfigureAwait(false);
await LogVariableAsync(EnvironmentVariableConstants.TESTINGPLATFORM_TESTHOSTCONTROLLER_TESTHOSTPROCESSSTARTTIME).ConfigureAwait(false);
async Task LogVariableAsync(string key)
{
string? value;
key = $"{key}_{testHostControllerPID}";
if ((value = environment.GetEnvironmentVariable(key)) is not null)
{
await logger.LogDebugAsync($"{key} '{value}'").ConfigureAwait(false);
}
}
}
await logger.LogInformationAsync($"{EnvironmentVariableConstants.TESTINGPLATFORM_DEFAULT_HANG_TIMEOUT}: '{environment.GetEnvironmentVariable(EnvironmentVariableConstants.TESTINGPLATFORM_DEFAULT_HANG_TIMEOUT)}'").ConfigureAwait(false);
}
///
public void Dispose()
=> (_host as IDisposable)?.Dispose();
///
public async Task RunAsync()
=> await _host.RunAsync().ConfigureAwait(false);
private static void AttachDebuggerIfNeeded(SystemEnvironment environment, SystemConsole console, SystemProcessHandler systemProcess)
{
if (environment.GetEnvironmentVariable(EnvironmentVariableConstants.TESTINGPLATFORM_LAUNCH_ATTACH_DEBUGGER) == "1")
{
Debugger.Launch();
}
if (environment.GetEnvironmentVariable(EnvironmentVariableConstants.TESTINGPLATFORM_WAIT_ATTACH_DEBUGGER) == "1")
{
if (OperatingSystem.IsBrowser())
{
throw new PlatformNotSupportedException(PlatformResources.WaitDebuggerAttachNotSupportedInBrowserErrorMessage);
}
if (OperatingSystem.IsWasi())
{
throw new PlatformNotSupportedException(PlatformResources.WaitDebuggerAttachNotSupportedInWasiErrorMessage);
}
WaitForDebuggerToAttach(environment, console, systemProcess);
}
}
[UnsupportedOSPlatform("browser")]
[UnsupportedOSPlatform("wasi")]
private static void WaitForDebuggerToAttach(SystemEnvironment environment, SystemConsole console, SystemProcessHandler systemProcess)
{
using IProcess currentProcess = systemProcess.GetCurrentProcess();
console.WriteLine($"Waiting for debugger to attach... Process Id: {environment.ProcessId}, Name: {currentProcess.Name}");
while (!Debugger.IsAttached)
{
Thread.Sleep(1000);
}
Debugger.Break();
}
/*
The expected order for the final logs directory is (most to least important):
1 Environment variable
2 Command line
3 TA settings(json)
4 Default(TestResults in the current working folder)
*/
private static ApplicationLoggingState CreateFileLoggerIfDiagnosticIsEnabled(
CommandLineParseResult result, CurrentTestApplicationModuleInfo testApplicationModuleInfo, SystemClock clock,
SystemEnvironment environment, SystemTask task, SystemConsole console)
{
LogLevel logLevel = LogLevel.None;
if (result.HasError)
{
return new(logLevel, result);
}
string? environmentVariable = environment.GetEnvironmentVariable(EnvironmentVariableConstants.TESTINGPLATFORM_DIAGNOSTIC);
if (!result.IsOptionSet(PlatformCommandLineProvider.DiagnosticOptionKey))
{
// Environment variable is set, but the command line option is not set
if (environmentVariable != "1")
{
return new(logLevel, result);
}
}
// Environment variable is set to 0 and takes precedence over the command line option
if (environmentVariable == "0")
{
return new(logLevel, result);
}
logLevel = LogLevel.Trace;
if (result.TryGetOptionArgumentList(PlatformCommandLineProvider.DiagnosticVerbosityOptionKey, out string[]? verbosity))
{
#if NETCOREAPP
logLevel = Enum.Parse(verbosity[0], true);
#else
logLevel = (LogLevel)Enum.Parse(typeof(LogLevel), verbosity[0], true);
#endif
}
// Override the log level if the environment variable is set
string? environmentLogLevel = environment.GetEnvironmentVariable(EnvironmentVariableConstants.TESTINGPLATFORM_DIAGNOSTIC_VERBOSITY);
if (TryParseDiagnosticVerbosity(environmentLogLevel, out LogLevel parsedLogLevel))
{
logLevel = parsedLogLevel;
}
// Set the directory to the default test result directory
string directory = GetDiagnosticDefaultDirectory(environment, testApplicationModuleInfo);
bool customDirectory = false;
if (result.TryGetOptionArgumentList(PlatformCommandLineProvider.ResultDirectoryOptionKey, out string[]? resultDirectoryArg))
{
directory = resultDirectoryArg[0];
customDirectory = true;
}
if (result.TryGetOptionArgumentList(PlatformCommandLineProvider.DiagnosticOutputDirectoryOptionKey, out string[]? directoryArg))
{
directory = directoryArg[0];
customDirectory = true;
}
// Override the output directory
string? environmentOutputDirectory = environment.GetEnvironmentVariable(EnvironmentVariableConstants.TESTINGPLATFORM_DIAGNOSTIC_OUTPUT_DIRECTORY);
if (!RoslynString.IsNullOrEmpty(environmentOutputDirectory))
{
directory = environmentOutputDirectory;
customDirectory = true;
}
// Finally create the directory
Directory.CreateDirectory(directory);
// Default prefix uses the deterministic __ shape so multi-target /
// multi-arch runs land in distinct files. The retry loop in FileLogger still appends a
// timestamp suffix, which preserves uniqueness across reruns. Users can override via
// --diagnostic-file-prefix or the TESTINGPLATFORM_DIAGNOSTIC_FILE_PREFIX env var.
string defaultAsm = testApplicationModuleInfo.TryGetAssemblyName() ?? "log";
string defaultTfm = TargetFrameworkParser.GetShortTargetFramework(Assembly.GetEntryAssembly()?.GetCustomAttribute()?.FrameworkDisplayName)
?? TargetFrameworkParser.GetShortTargetFramework(RuntimeInformation.FrameworkDescription)
?? "unknown";
string defaultArch = RuntimeInformation.ProcessArchitecture.ToString().ToLowerInvariant();
string prefixName = $"{defaultAsm}_{defaultTfm}_{defaultArch}";
if (result.TryGetOptionArgumentList(PlatformCommandLineProvider.DiagnosticOutputFilePrefixOptionKey, out string[]? prefixNameArg))
{
prefixName = prefixNameArg[0];
}
// Override the prefix name.
// Prefer the new TESTINGPLATFORM_DIAGNOSTIC_FILE_PREFIX env var (matching the --diagnostic-file-prefix CLI option),
// but fall back to the legacy TESTINGPLATFORM_DIAGNOSTIC_OUTPUT_FILEPREFIX for backward compatibility.
// See https://github.com/microsoft/testfx/issues/7159.
string? environmentFilePrefix = environment.GetEnvironmentVariable(EnvironmentVariableConstants.TESTINGPLATFORM_DIAGNOSTIC_FILE_PREFIX);
#pragma warning disable CS0618 // Type or member is obsolete - intentional back-compat fallback to the legacy env var.
string? legacyEnvironmentFilePrefix = environment.GetEnvironmentVariable(EnvironmentVariableConstants.TESTINGPLATFORM_DIAGNOSTIC_OUTPUT_FILEPREFIX);
if (!RoslynString.IsNullOrEmpty(legacyEnvironmentFilePrefix))
{
console.WriteLine(string.Format(
CultureInfo.InvariantCulture,
PlatformResources.DeprecatedEnvironmentVariableWarning,
EnvironmentVariableConstants.TESTINGPLATFORM_DIAGNOSTIC_OUTPUT_FILEPREFIX,
EnvironmentVariableConstants.TESTINGPLATFORM_DIAGNOSTIC_FILE_PREFIX));
}
#pragma warning restore CS0618
if (RoslynString.IsNullOrEmpty(environmentFilePrefix))
{
environmentFilePrefix = legacyEnvironmentFilePrefix;
}
if (!RoslynString.IsNullOrEmpty(environmentFilePrefix))
{
prefixName = environmentFilePrefix;
}
bool synchronousWrite = result.IsOptionSet(PlatformCommandLineProvider.DiagnosticFileLoggerSynchronousWriteOptionKey);
// Override the synchronous write.
// Prefer the new TESTINGPLATFORM_DIAGNOSTIC_SYNCHRONOUS_WRITE env var (matching the --diagnostic-synchronous-write CLI option),
// but fall back to the legacy TESTINGPLATFORM_DIAGNOSTIC_FILELOGGER_SYNCHRONOUSWRITE for backward compatibility.
// See https://github.com/microsoft/testfx/issues/7159.
string? environmentSynchronousWrite = environment.GetEnvironmentVariable(EnvironmentVariableConstants.TESTINGPLATFORM_DIAGNOSTIC_SYNCHRONOUS_WRITE);
#pragma warning disable CS0618 // Type or member is obsolete - intentional back-compat fallback to the legacy env var.
string? legacyEnvironmentSynchronousWrite = environment.GetEnvironmentVariable(EnvironmentVariableConstants.TESTINGPLATFORM_DIAGNOSTIC_FILELOGGER_SYNCHRONOUSWRITE);
if (!RoslynString.IsNullOrEmpty(legacyEnvironmentSynchronousWrite))
{
console.WriteLine(string.Format(
CultureInfo.InvariantCulture,
PlatformResources.DeprecatedEnvironmentVariableWarning,
EnvironmentVariableConstants.TESTINGPLATFORM_DIAGNOSTIC_FILELOGGER_SYNCHRONOUSWRITE,
EnvironmentVariableConstants.TESTINGPLATFORM_DIAGNOSTIC_SYNCHRONOUS_WRITE));
}
#pragma warning restore CS0618
if (RoslynString.IsNullOrEmpty(environmentSynchronousWrite))
{
environmentSynchronousWrite = legacyEnvironmentSynchronousWrite;
}
if (!RoslynString.IsNullOrEmpty(environmentSynchronousWrite))
{
synchronousWrite = environmentSynchronousWrite == "1";
}
return new(
logLevel,
result,
new(
new FileLoggerOptions(
directory,
prefixName,
fileName: null,
synchronousWrite),
logLevel,
customDirectory,
clock,
task,
console,
new SystemFileSystem(),
new SystemFileStreamFactory()),
synchronousWrite);
}
internal /* for testing purposes */ static bool TryParseDiagnosticVerbosity(string? environmentLogLevel, out LogLevel parsedLogLevel)
{
parsedLogLevel = LogLevel.None;
return !RoslynString.IsNullOrEmpty(environmentLogLevel)
&& (Enum.TryParse(environmentLogLevel, ignoreCase: true, out parsedLogLevel)
|| ThrowInvalidDiagnosticVerbosity(environmentLogLevel));
}
internal /* for testing purposes */ static string GetDiagnosticDefaultDirectory(IEnvironment environment, ITestApplicationModuleInfo testApplicationModuleInfo)
{
string? effectiveWorkingDirectory = environment.GetEnvironmentVariable(EnvironmentVariableConstants.DOTNET_CLI_TEST_COMMAND_WORKING_DIRECTORY);
if (RoslynString.IsNullOrWhiteSpace(effectiveWorkingDirectory))
{
effectiveWorkingDirectory = testApplicationModuleInfo.GetCurrentTestApplicationDirectory();
}
return Path.Combine(effectiveWorkingDirectory, AggregatedConfiguration.DefaultTestResultFolderName);
}
private static bool ThrowInvalidDiagnosticVerbosity(string environmentLogLevel)
=> throw new NotSupportedException($"Invalid environment value '{nameof(EnvironmentVariableConstants.TESTINGPLATFORM_DIAGNOSTIC_VERBOSITY)}', was expecting 'Trace', 'Debug', 'Information', 'Warning', 'Error', or 'Critical' but got '{environmentLogLevel}'.");
}