// 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}'."); }