// 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.Extensions.TrxReport.Abstractions; using Microsoft.Testing.Platform.Capabilities.TestFramework; using Microsoft.Testing.Platform.Configurations; using Microsoft.Testing.Platform.Extensions.Messages; using Microsoft.Testing.Platform.Extensions.TestFramework; using Microsoft.Testing.Platform.Helpers; using Microsoft.Testing.Platform.Logging; using Microsoft.Testing.Platform.Messages; using Microsoft.Testing.Platform.Requests; using Microsoft.Testing.Platform.Services; using Microsoft.Testing.Platform.Telemetry; using Microsoft.Testing.Platform.TestHost; using Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter; using Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.Execution; using Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.Resources; using Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.TestingPlatformAdapter; using Microsoft.VisualStudio.TestPlatform.MSTestAdapter.PlatformServices; using CreateTestSessionContext = Microsoft.Testing.Platform.Extensions.TestFramework.CreateTestSessionContext; namespace Microsoft.VisualStudio.TestTools.UnitTesting; /// /// A native Microsoft.Testing.Platform (MTP) test framework for MSTest. It does not derive from the VSTest bridge /// and it no longer routes through the VSTest MSTestDiscoverer / MSTestExecutor classes: it handles the /// MTP discovery/run requests directly, builds the run context, filter and runsettings natively /// (see / / ), /// publishes test nodes through the native / , /// and drives discovery/execution through the platform-agnostic . /// [SuppressMessage("ApiDesign", "RS0030:Do not use banned APIs", Justification = "We can use MTP from this folder")] [StackTraceHidden] internal sealed class MSTestTestFramework : ITestFramework, IDataProducer, IDisposable { private readonly MSTestExtension _extension; private readonly Func> _getTestAssemblies; private readonly IServiceProvider _serviceProvider; private readonly ITrxReportCapability? _trxReportCapability; private readonly PlatformServicesConfigurationAdapter _configuration; private readonly ILoggerFactory _loggerFactory; private readonly MSTestGracefulStopTestExecutionCapability _gracefulStopCapability; private readonly string _resultFilesStagingDirectory; private readonly CountdownEvent _incomingRequestCounter = new(1); private int _nextResultFileStagingDirectory; private int _resultFilesStagingDirectoryCreated; private bool? _isTrxEnabled; private bool _isDisposed; private SessionUid? _sessionUid; public MSTestTestFramework(MSTestExtension extension, Func> getTestAssemblies, IServiceProvider serviceProvider, ITestFrameworkCapabilities capabilities) { _extension = extension; _getTestAssemblies = getTestAssemblies; _serviceProvider = serviceProvider; _trxReportCapability = capabilities.GetCapability(); _configuration = new(serviceProvider.GetConfiguration()); _loggerFactory = serviceProvider.GetRequiredService(); _gracefulStopCapability = (MSTestGracefulStopTestExecutionCapability)capabilities.GetCapability()!; _resultFilesStagingDirectory = Path.Combine( serviceProvider.GetConfiguration().GetTestResultDirectory(), $".mstest-{Guid.NewGuid():N}"[..16]); ((Microsoft.Testing.Platform.Services.ServiceProvider)serviceProvider) .AddService(new ResultFilesStagingDirectoryCleanup(CleanupResultFilesStagingDirectory)); PlatformServiceProvider.Instance.AdapterTraceLogger = new MTPTraceLogger(_loggerFactory.CreateLogger("mstest-trace")); _gracefulStopCapability.NotifyTestExecutionPending(); // Let the engine emit fixture/test-method spans that nest under the platform's test-case spans. This is a // no-op unless the OpenTelemetry extension is registered. MSTestPlatformActivity.TryEnable(serviceProvider); } internal ITestClassInstanceFactory? TestClassInstanceFactory { get; set; } internal MSTestDiscoveryCache? DiscoveryCache { get; set; } public string Uid => _extension.Uid; public string Version => _extension.Version; public string DisplayName => _extension.DisplayName; public string Description => _extension.Description; public Type[] DataTypesProduced { get; } = [ typeof(TestNodeUpdateMessage), typeof(SessionFileArtifact), ]; private bool IsTrxEnabled => _isTrxEnabled ??= _trxReportCapability is IMSTestTrxReportCapability internalCapability ? internalCapability.IsTrxEnabled : _trxReportCapability is { IsSupported: true }; public Task IsEnabledAsync() => _extension.IsEnabledAsync(); public Task CreateTestSessionAsync(CreateTestSessionContext context) { context.CancellationToken.ThrowIfCancellationRequested(); if (_sessionUid is not null) { throw new InvalidOperationException(string.Format(CultureInfo.CurrentCulture, PlatformAdapterResources.VSTestBridgedTestFrameworkSessionAlreadyCreatedErrorMessage, _sessionUid.Value.Value)); } _sessionUid = context.SessionUid; return Task.FromResult(new CreateTestSessionResult { IsSuccess = true }); } public async Task CloseTestSessionAsync(CloseTestSessionContext context) { _incomingRequestCounter.Signal(); await _incomingRequestCounter.WaitAsync(context.CancellationToken).ConfigureAwait(false); _sessionUid = null; return new CloseTestSessionResult { IsSuccess = true }; } public async Task ExecuteRequestAsync(ExecuteRequestContext context) { _incomingRequestCounter.AddCount(); try { switch (context.Request) { case DiscoverTestExecutionRequest discoverRequest: await DiscoverTestsAsync(discoverRequest, context.MessageBus, context.CancellationToken).ConfigureAwait(false); break; case RunTestExecutionRequest runRequest: await RunTestsAsync(runRequest, context).ConfigureAwait(false); break; default: throw new NotSupportedException($"The native MSTest framework does not support requests of type '{context.Request.GetType()}'."); } } finally { _incomingRequestCounter.Signal(); context.Complete(); } } private async Task DiscoverTestsAsync(DiscoverTestExecutionRequest request, IMessageBus messageBus, CancellationToken cancellationToken) { if (Environment.GetEnvironmentVariable("MSTEST_DEBUG_DISCOVERTESTS") == "1" && !Debugger.IsAttached) { Debugger.Launch(); } SessionUid sessionUid = _sessionUid!.Value; string[] assemblyPaths = GetAssemblyPaths(); var handle = new MSTestFrameworkHandle(_serviceProvider.GetOutputDevice(), _extension, cancellationToken); MSTestRunSettings runSettings = CreateRunSettings(handle); var discoveryContext = new MSTestDiscoveryContext(_serviceProvider.GetCommandLineOptions(), runSettings, request.Filter); var elementSink = new MtpUnitTestElementSink(messageBus, this, sessionUid, IsTrxEnabled); // Call the platform-agnostic engine directly with neutral inputs; the native MTP path no longer routes // through the VSTest MSTestDiscoverer class. The MTP-specific filter provider evaluates the filter from the // neutral UnitTestElement model so this path never materializes a vstest TestCase (see #9769). var engine = new MSTestEngine(cancellationToken, CreateTelemetrySender()) { TestClassInstanceFactory = TestClassInstanceFactory, }; await engine.DiscoverAsync( assemblyPaths, runSettings.SettingsXml, handle.ToAdapterMessageLogger(), elementSink, new MtpTestElementFilterProvider(discoveryContext), _configuration, new TestSourceHandler(), isMTP: true) .ConfigureAwait(false); } private async Task RunTestsAsync(RunTestExecutionRequest request, ExecuteRequestContext context) { CancellationToken cancellationToken = context.CancellationToken; if (Environment.GetEnvironmentVariable("MSTEST_DEBUG_RUNTESTS") == "1" && !Debugger.IsAttached) { Debugger.Launch(); } SessionUid sessionUid = _sessionUid!.Value; Assembly[] assemblies = [.. _getTestAssemblies()]; string[] assemblyPaths = [.. assemblies.Select(GetAssemblyPath)]; var handle = new MSTestFrameworkHandle(_serviceProvider.GetOutputDevice(), _extension, cancellationToken); MSTestRunSettings runSettings = CreateRunSettings(handle); var runContext = new MSTestRunContext(_serviceProvider.GetCommandLineOptions(), runSettings, request.Filter); // Call the platform-agnostic engine directly with neutral inputs; the native MTP path no longer routes // through the VSTest MSTestExecutor class. Results are published natively via MtpTestResultRecorder and the // MTP-specific filter provider evaluates the filter from the neutral UnitTestElement model so this path // never materializes a vstest TestCase (see #9769). _gracefulStopCapability.NotifyTestExecutionStarting(); try { var executionManager = new TestExecutionManager(); string? enableCache = Environment.GetEnvironmentVariable(MSTestDiscoveryCache.EnableEnvironmentVariable); string? disableCache = Environment.GetEnvironmentVariable(MSTestDiscoveryCache.DisableEnvironmentVariable); if (enableCache is "1" or "true" && disableCache is not ("1" or "true") && DiscoveryCache is { } cache && TestClassInstanceFactory is null // Match MTP's JSON-RPC protocol resolution, not its one-shot dotnet-test pipe. && _serviceProvider.GetCommandLineOptions().TryGetOptionArgumentList("server", out string[]? protocolName) && (protocolName is null || protocolName.Length == 0 || protocolName[0].Equals("jsonrpc", StringComparison.OrdinalIgnoreCase)) && request.Filter is TestNodeUidListFilter uidFilter && uidFilter.TestNodeUids.All(uid => Guid.TryParse(uid.Value, out _))) { cache.SetSources(assemblyPaths); executionManager.UnitTestDiscovererFactory = sourceHandler => cache.CreateDiscoverer(sourceHandler, assemblies, uidFilter.TestNodeUids, cancellationToken); } var engine = new MSTestEngine(cancellationToken, CreateTelemetrySender(), executionManager) { TestClassInstanceFactory = TestClassInstanceFactory, }; await engine.RunFromSourcesAsync( assemblyPaths, runSettings.SettingsXml, runContext.TestRunDirectory, handle.ToAdapterMessageLogger(), settings => new MtpTestResultRecorder( context, this, sessionUid, IsTrxEnabled, settings, StageResultFiles), new MtpTestElementFilterProvider(runContext), _configuration, new TestSourceHandler(), isMTP: true) .ConfigureAwait(false); } finally { _gracefulStopCapability.NotifyTestExecutionCompleted(); } } private MSTestRunSettings CreateRunSettings(MSTestFrameworkHandle handle) => new( _serviceProvider.GetCommandLineOptions(), _serviceProvider.GetFileSystem(), _serviceProvider.GetConfiguration(), _serviceProvider.GetClientInfo(), handle); private string[] GetAssemblyPaths() => [.. _getTestAssemblies().Select(GetAssemblyPath)]; [UnconditionalSuppressMessage("SingleFile", "IL3000:Avoid accessing Assembly file path when publishing as a single file", Justification = "Empty Assembly.Location is handled explicitly by falling back to the assembly simple name.")] private static string GetAssemblyPath(Assembly assembly) { string location = assembly.Location; if (!string.IsNullOrEmpty(location)) { return location; } string name = assembly.GetName().Name ?? throw new InvalidOperationException($"Cannot determine the name of assembly '{assembly}'."); return name + ".dll"; } private Func, Task>? CreateTelemetrySender() { ITelemetryInformation telemetryInformation = _serviceProvider.GetTelemetryInformation(); if (!telemetryInformation.IsEnabled) { return null; } ITelemetryCollector telemetryCollector = _serviceProvider.GetTelemetryCollector(); return (eventName, metrics) => telemetryCollector.LogEventAsync(eventName, metrics, CancellationToken.None); } private void StageResultFiles(TestResult result) { if (result.ResultFiles is not { Count: > 0 } resultFiles) { return; } var durableResultFiles = new List(resultFiles.Count); foreach (string resultFile in resultFiles) { string sourcePath = PlatformServiceProvider.Instance.FileOperations.GetFullFilePath(resultFile); try { int directoryId = Interlocked.Increment(ref _nextResultFileStagingDirectory); string stagingDirectory = Path.Combine(_resultFilesStagingDirectory, directoryId.ToString(CultureInfo.InvariantCulture)); string stagedPath = Path.Combine(stagingDirectory, GetFileName(sourcePath)); Directory.CreateDirectory(GetPathForFileSystemAccess(stagingDirectory)); Volatile.Write(ref _resultFilesStagingDirectoryCreated, 1); File.Copy( GetPathForFileSystemAccess(sourcePath), GetPathForFileSystemAccess(stagedPath)); durableResultFiles.Add(stagedPath); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { // Preserve the existing reporter behavior: leave the original path in place so each // consumer can surface its normal attachment-copy warning or failure. durableResultFiles.Add(resultFile); } } result.ResultFiles = durableResultFiles; } private static string GetPathForFileSystemAccess(string path) { #if NETCOREAPP return path; #else if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows) || path.StartsWith(@"\\?\", StringComparison.Ordinal) || path.StartsWith(@"\\.\", StringComparison.Ordinal)) { return path; } string fullPath; try { fullPath = Path.GetFullPath(path); } catch (PathTooLongException) when (Path.IsPathRooted(path) && !ContainsRelativePathSegments(path)) { fullPath = path; } catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException) { return path; } return fullPath.Length < 260 ? path : fullPath.StartsWith(@"\\", StringComparison.Ordinal) ? @"\\?\UNC\" + fullPath.Substring(2) : @"\\?\" + fullPath; #endif } private static string GetFileName(string path) { int separatorIndex = path.LastIndexOfAny([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar]); return separatorIndex < 0 ? path : path.Substring(separatorIndex + 1); } #if !NETCOREAPP private static bool ContainsRelativePathSegments(string path) { foreach (string segment in path.Split([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], StringSplitOptions.RemoveEmptyEntries)) { if (segment is "." or "..") { return true; } } return false; } #endif private void CleanupResultFilesStagingDirectory() { if (Volatile.Read(ref _resultFilesStagingDirectoryCreated) == 0) { return; } try { Directory.Delete(GetPathForFileSystemAccess(_resultFilesStagingDirectory), recursive: true); } catch (DirectoryNotFoundException) { // The desired cleanup state has already been reached. } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { PlatformServiceProvider.Instance.AdapterTraceLogger.Warning( "Failed to delete result-file staging directory '{0}': {1}", _resultFilesStagingDirectory, ex); } } public void Dispose() { if (!_isDisposed) { _gracefulStopCapability.NotifyTestExecutionCompleted(); CleanupResultFilesStagingDirectory(); _incomingRequestCounter.Dispose(); _isDisposed = true; } } private sealed class ResultFilesStagingDirectoryCleanup(Action cleanup) : IDisposable { private Action? _cleanup = cleanup; public void Dispose() => Interlocked.Exchange(ref _cleanup, null)?.Invoke(); } }