diff --git a/src/Adapter/MSTest.TestAdapter/MSTest.TestAdapter.csproj b/src/Adapter/MSTest.TestAdapter/MSTest.TestAdapter.csproj index 23655fc099..893affebd3 100644 --- a/src/Adapter/MSTest.TestAdapter/MSTest.TestAdapter.csproj +++ b/src/Adapter/MSTest.TestAdapter/MSTest.TestAdapter.csproj @@ -88,6 +88,11 @@ reference is not added for UWP (see above), and PlatformServices no longer references the object model, so it is referenced explicitly here for UWP. --> + + + diff --git a/src/Adapter/MSTest.TestAdapter/TestingPlatformAdapter/MSTestBridgedTestFramework.cs b/src/Adapter/MSTest.TestAdapter/TestingPlatformAdapter/MSTestBridgedTestFramework.cs index fa5d713b45..e175c89ce1 100644 --- a/src/Adapter/MSTest.TestAdapter/TestingPlatformAdapter/MSTestBridgedTestFramework.cs +++ b/src/Adapter/MSTest.TestAdapter/TestingPlatformAdapter/MSTestBridgedTestFramework.cs @@ -22,12 +22,6 @@ namespace Microsoft.VisualStudio.TestTools.UnitTesting; [StackTraceHidden] internal sealed class MSTestBridgedTestFramework : SynchronizedSingleSessionVSTestBridgedTestFramework { - // Opt-in switch for the experimental native Microsoft.Testing.Platform integration: when enabled, MSTest - // publishes test nodes directly from its neutral execution model instead of routing discovery and results - // through the VSTest bridge object model. Off by default, so the shipping behavior is unchanged. - private static readonly bool UseNativeMtpProduction = - Environment.GetEnvironmentVariable("MSTEST_EXPERIMENTAL_NATIVE_MTP") is "1" or "true" or "True"; - private readonly BridgedConfiguration? _configuration; private readonly ILoggerFactory _loggerFactory; @@ -50,16 +44,7 @@ protected override Task SynchronizedDiscoverTestsAsync(VSTestDiscoverTestExecuti Debugger.Launch(); } - var discoverer = new MSTestDiscoverer(new TestSourceHandler(), CreateTelemetrySender()); - if (UseNativeMtpProduction && SessionUid is { } sessionUid) - { - // Publish discovered test nodes directly from the neutral model; the VSTest discovery sink in the - // request is bypassed (no VSTest TestCase materialization / ObjectModelConverters round-trip). - var elementSink = new MtpUnitTestElementSink(messageBus, this, sessionUid, IsTrxEnabled); - return discoverer.DiscoverTestsAsync(request.AssemblyPaths, request.DiscoveryContext, request.MessageLogger, elementSink, _configuration, isMTP: true); - } - - return discoverer.DiscoverTestsAsync(request.AssemblyPaths, request.DiscoveryContext, request.MessageLogger, request.DiscoverySink, _configuration, isMTP: true); + return new MSTestDiscoverer(new TestSourceHandler(), CreateTelemetrySender()).DiscoverTestsAsync(request.AssemblyPaths, request.DiscoveryContext, request.MessageLogger, request.DiscoverySink, _configuration, isMTP: true); } /// @@ -73,20 +58,6 @@ protected override async Task SynchronizedRunTestsAsync(VSTestRunTestExecutionRe } MSTestExecutor testExecutor = new(cancellationToken, CreateTelemetrySender()); - if (UseNativeMtpProduction && SessionUid is { } sessionUid) - { - // Report results directly as native test nodes; the framework handle is still used for message logging - // and apartment-state handling, but its VSTest RecordResult / ObjectModelConverters path is bypassed. - await testExecutor.RunTestsAsync( - request.AssemblyPaths, - request.RunContext, - request.FrameworkHandle, - settings => new MtpTestResultRecorder(messageBus, this, sessionUid, IsTrxEnabled, settings), - _configuration, - isMTP: true).ConfigureAwait(false); - return; - } - await testExecutor.RunTestsAsync(request.AssemblyPaths, request.RunContext, request.FrameworkHandle, _configuration, isMTP: true).ConfigureAwait(false); } diff --git a/src/Adapter/MSTest.TestAdapter/TestingPlatformAdapter/MSTestFilterContext.cs b/src/Adapter/MSTest.TestAdapter/TestingPlatformAdapter/MSTestFilterContext.cs new file mode 100644 index 0000000000..ca393aefe4 --- /dev/null +++ b/src/Adapter/MSTest.TestAdapter/TestingPlatformAdapter/MSTestFilterContext.cs @@ -0,0 +1,225 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +#if !WINDOWS_UWP +using Microsoft.Testing.Platform.CommandLine; +using Microsoft.Testing.Platform.Extensions.Messages; +using Microsoft.Testing.Platform.Requests; +using Microsoft.VisualStudio.TestPlatform.Common.Filtering; +using Microsoft.VisualStudio.TestPlatform.ObjectModel; +using Microsoft.VisualStudio.TestPlatform.ObjectModel.Adapter; + +namespace Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.TestingPlatformAdapter; + +/// +/// A native Microsoft.Testing.Platform (MTP) filter context for MSTest. It builds a VSTest +/// (which MSTest's TestMethodFilter evaluates over +/// UnitTestElements) directly from the MTP , the --filter +/// command-line option and the runsettings <TestCaseFilter> — without going through the VSTest bridge's +/// context adapters. +/// +/// +/// This mirrors, for the MSTest native path, what ContextAdapterBase / RunContextAdapter / +/// DiscoveryContextAdapter do in Microsoft.Testing.Extensions.VSTestBridge. The filter-expression +/// parsing itself reuses the VSTest Microsoft.TestPlatform.Filter.Source package, exactly like the bridge. +/// +[SuppressMessage("ApiDesign", "RS0030:Do not use banned APIs", Justification = "We can use MTP from this folder")] +internal abstract class MSTestFilterContextBase +{ + // Keep in sync with the bridge's TestCaseFilterCommandLineOptionsProvider.TestCaseFilterOptionName. + private const string TestCaseFilterOptionName = "filter"; + + protected MSTestFilterContextBase(ICommandLineOptions commandLineOptions, IRunSettings runSettings, ITestExecutionFilter filter) + { + RunSettings = runSettings; + + string? filterFromRunsettings = runSettings.SettingsXml is null + ? null + : XDocument.Parse(runSettings.SettingsXml).Element("RunSettings")?.Element("RunConfiguration")?.Element("TestCaseFilter")?.Value; + + string? filterFromCommandLineOption = null; + if (commandLineOptions.TryGetOptionArgumentList(TestCaseFilterOptionName, out string[]? filterExpressions) + && filterExpressions is not null + && filterExpressions.Length == 1) + { + filterFromCommandLineOption = filterExpressions[0]; + } + + HandleFilter(filter, filterFromRunsettings, filterFromCommandLineOption); + } + + public IRunSettings? RunSettings { get; } + + private FilterExpressionWrapper? FilterExpressionWrapper { get; set; } + + private sealed class MSTestFilterExpression : ITestCaseFilterExpression + { + private readonly TestCaseFilterExpression _testCaseFilterExpression; + + public MSTestFilterExpression(TestCaseFilterExpression testCaseFilterExpression) + => _testCaseFilterExpression = testCaseFilterExpression; + + public string TestCaseFilterValue + => _testCaseFilterExpression.TestCaseFilterValue; + + public bool MatchTestCase(TestCase testCase, Func propertyValueProvider) + => _testCaseFilterExpression.MatchTestCase(propertyValueProvider); + } + + // NOTE: MSTest.TestAdapter's TestMethodFilter accesses this method via reflection on the discovery context + // (see GetTestCaseFilterFromDiscoveryContext) and directly on the run context. +#pragma warning disable IDE0060 // Remove unused parameter - kept for the VSTest GetTestCaseFilter contract shape. + public ITestCaseFilterExpression? GetTestCaseFilter( + IEnumerable? supportedProperties, + Func propertyProvider) +#pragma warning restore IDE0060 + => FilterExpressionWrapper is null + ? null + : !string.IsNullOrEmpty(FilterExpressionWrapper.ParseError) + ? throw new TestPlatformFormatException(FilterExpressionWrapper.ParseError, FilterExpressionWrapper.FilterString) + : new MSTestFilterExpression(new TestCaseFilterExpression(FilterExpressionWrapper)); + + private void HandleFilter(ITestExecutionFilter? filter, string? filterFromRunsettings, string? filterFromCommandLineOption) + { + if (filter is null or NopFilter + && filterFromRunsettings is null + && filterFromCommandLineOption is null) + { + return; + } + + var filterBuilder = new StringBuilder(); + + AppendFilter(filterFromRunsettings, filterBuilder); + AppendFilter(filterFromCommandLineOption, filterBuilder); + + if (filter is TestNodeUidListFilter testNodeUidListFilter) + { + StartFilter(filterBuilder); + BuildFilter(testNodeUidListFilter.TestNodeUids, filterBuilder); + EndFilter(filterBuilder); + } + + if (filterBuilder.Length > 0) + { + FilterExpressionWrapper = new FilterExpressionWrapper(filterBuilder.ToString()); + } + + static void AppendFilter(string? filter, StringBuilder builder) + { + if (filter is null) + { + return; + } + + StartFilter(builder); + builder.Append(filter); + EndFilter(builder); + } + + static void StartFilter(StringBuilder builder) + { + if (builder.Length > 0) + { + builder.Append(" & ("); + } + else + { + builder.Append('('); + } + } + + static void EndFilter(StringBuilder builder) + => builder.Append(')'); + } + + // Heuristic borrowed from the bridge: a GUID TestNodeUid maps to a VSTest Id filter, everything else to a + // (escaped) FullyQualifiedName filter. + private static void BuildFilter(TestNodeUid[] testNodesUid, StringBuilder filter) + { + for (int i = 0; i < testNodesUid.Length; i++) + { + if (i > 0) + { + filter.Append('|'); + } + + if (Guid.TryParse(testNodesUid[i].Value, out Guid guid)) + { + filter.Append("Id="); + filter.Append(guid.ToString()); + continue; + } + + TestNodeUid currentTestNodeUid = testNodesUid[i]; + filter.Append("FullyQualifiedName="); + for (int k = 0; k < currentTestNodeUid.Value.Length; k++) + { + char currentChar = currentTestNodeUid.Value[k]; + switch (currentChar) + { + case '\\': + case '(': + case ')': + case '&': + case '|': + case '=': + case '!': + case '~': + // Escape the special char if it is not already escaped. Note: index into k-1 (the previous + // character in this UID value), not i-1. + if (k - 1 < 0 || currentTestNodeUid.Value[k - 1] != '\\') + { + filter.Append('\\'); + } + + filter.Append(currentChar); + break; + + default: + filter.Append(currentChar); + break; + } + } + } + } +} + +/// +/// Native MTP implementation of the VSTest for the MSTest native path. +/// +[SuppressMessage("ApiDesign", "RS0030:Do not use banned APIs", Justification = "We can use MTP from this folder")] +internal sealed class MSTestRunContext : MSTestFilterContextBase, IRunContext +{ + public MSTestRunContext(ICommandLineOptions commandLineOptions, IRunSettings runSettings, ITestExecutionFilter filter) + : base(commandLineOptions, runSettings, filter) + => TestRunDirectory = runSettings.SettingsXml is null + ? null + : XDocument.Parse(runSettings.SettingsXml).Element("RunSettings")?.Element("RunConfiguration")?.Element("ResultsDirectory")?.Value; + + // TPv2-oriented flags that do not apply to the platform adapter (mirrors RunContextAdapter). + public bool KeepAlive { get; } + + public bool InIsolation { get; } + + public bool IsDataCollectionEnabled { get; } + + public bool IsBeingDebugged => Debugger.IsAttached; + + public string? TestRunDirectory { get; } + + public string? SolutionDirectory { get; } +} + +/// +/// Native MTP implementation of the VSTest for the MSTest native path. +/// +[SuppressMessage("ApiDesign", "RS0030:Do not use banned APIs", Justification = "We can use MTP from this folder")] +internal sealed class MSTestDiscoveryContext : MSTestFilterContextBase, IDiscoveryContext +{ + public MSTestDiscoveryContext(ICommandLineOptions commandLineOptions, IRunSettings runSettings, ITestExecutionFilter filter) + : base(commandLineOptions, runSettings, filter) + { + } +} +#endif diff --git a/src/Adapter/MSTest.TestAdapter/TestingPlatformAdapter/MSTestFrameworkHandle.cs b/src/Adapter/MSTest.TestAdapter/TestingPlatformAdapter/MSTestFrameworkHandle.cs new file mode 100644 index 0000000000..e6859a27a7 --- /dev/null +++ b/src/Adapter/MSTest.TestAdapter/TestingPlatformAdapter/MSTestFrameworkHandle.cs @@ -0,0 +1,78 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +#if !WINDOWS_UWP +using Microsoft.Testing.Platform.Extensions; +using Microsoft.Testing.Platform.Extensions.OutputDevice; +using Microsoft.Testing.Platform.OutputDevice; +using Microsoft.VisualStudio.TestPlatform.ObjectModel; +using Microsoft.VisualStudio.TestPlatform.ObjectModel.Adapter; +using Microsoft.VisualStudio.TestPlatform.ObjectModel.Logging; + +namespace Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.TestingPlatformAdapter; + +/// +/// A native Microsoft.Testing.Platform (MTP) for the MSTest native path. It only +/// forwards diagnostic messages to the MTP ; test results are reported natively through +/// the MtpTestResultRecorder, so the VSTest result-recording members are intentionally no-ops. +/// +[SuppressMessage("ApiDesign", "RS0030:Do not use banned APIs", Justification = "We can use MTP from this folder")] +internal sealed class MSTestFrameworkHandle : IFrameworkHandle, IOutputDeviceDataProducer +{ + private readonly IOutputDevice _outputDevice; + private readonly IExtension _extension; + private readonly CancellationToken _cancellationToken; + + public MSTestFrameworkHandle(IOutputDevice outputDevice, IExtension extension, CancellationToken cancellationToken) + { + _outputDevice = outputDevice; + _extension = extension; + _cancellationToken = cancellationToken; + } + + public bool EnableShutdownAfterTestRun { get; set; } + + string IExtension.Uid => _extension.Uid; + + string IExtension.Version => _extension.Version; + + string IExtension.DisplayName => _extension.DisplayName; + + string IExtension.Description => _extension.Description; + + public int LaunchProcessWithDebuggerAttached(string filePath, string? workingDirectory, string? arguments, IDictionary? environmentVariables) + => -1; + + // Results are reported natively through the recorder; the VSTest recording members are not used. + public void RecordResult(TestResult testResult) + { + } + + public void RecordStart(TestCase testCase) + { + } + + public void RecordEnd(TestCase testCase, TestOutcome outcome) + { + } + + public void RecordAttachments(IList attachmentSets) + { + } + + public void SendMessage(TestMessageLevel testMessageLevel, string message) + { + IOutputDeviceData data = testMessageLevel switch + { + TestMessageLevel.Informational => new TextOutputDeviceData(message), + TestMessageLevel.Warning => new WarningMessageOutputDeviceData(message), + TestMessageLevel.Error => new ErrorMessageOutputDeviceData(message), + _ => throw new NotSupportedException($"Unsupported logging level '{testMessageLevel}'."), + }; + + _outputDevice.DisplayAsync(this, data, _cancellationToken).ConfigureAwait(false).GetAwaiter().GetResult(); + } + + Task IExtension.IsEnabledAsync() => _extension.IsEnabledAsync(); +} +#endif diff --git a/src/Adapter/MSTest.TestAdapter/TestingPlatformAdapter/MSTestRunSettings.cs b/src/Adapter/MSTest.TestAdapter/TestingPlatformAdapter/MSTestRunSettings.cs new file mode 100644 index 0000000000..65f5f63851 --- /dev/null +++ b/src/Adapter/MSTest.TestAdapter/TestingPlatformAdapter/MSTestRunSettings.cs @@ -0,0 +1,218 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +#if !WINDOWS_UWP +using Microsoft.Testing.Extensions.VSTestBridge.Resources; +using Microsoft.Testing.Platform.CommandLine; +using Microsoft.Testing.Platform.Configurations; +using Microsoft.Testing.Platform.Services; +using Microsoft.Testing.Platform.TestHost; +using Microsoft.VisualStudio.TestPlatform.ObjectModel.Adapter; +using Microsoft.VisualStudio.TestPlatform.ObjectModel.Logging; + +using IFileSystem = Microsoft.Testing.Platform.Helpers.IFileSystem; + +namespace Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.TestingPlatformAdapter; + +/// +/// A native Microsoft.Testing.Platform (MTP) implementation of the VSTest for the MSTest +/// native path. It reads the runsettings XML (from the --settings option or the runsettings environment +/// variables), patches it with MTP defaults (design mode, results directory, --test-parameter overrides) and +/// warns on entries MTP does not support — mirroring the VSTest bridge's RunSettingsAdapter / +/// RunSettingsPatcher / RunSettingsHelpers without depending on the bridge. +/// +[SuppressMessage("ApiDesign", "RS0030:Do not use banned APIs", Justification = "We can use MTP from this folder")] +internal sealed class MSTestRunSettings : IRunSettings +{ + // Keep in sync with the bridge's option provider constants (options are still registered by the bridge). + private const string RunSettingsOptionName = "settings"; + private const string TestRunParameterOptionName = "test-parameter"; + + private static readonly char[] TestRunParameterSeparator = ['=']; + + private static readonly string[] UnsupportedRunConfigurationSettings = + [ + "DotnetHostPath", + "MaxCpuCount", + "TargetFrameworkVersion", + "TargetPlatform", + "TestAdaptersPaths", + "TestSessionTimeout", + "TreatNoTestsAsError", + "TreatTestAdapterErrorsAsWarnings", + ]; + + public MSTestRunSettings( + ICommandLineOptions commandLineOptions, + IFileSystem fileSystem, + IConfiguration configuration, + IClientInfo client, + IMessageLogger messageLogger) + { + string runSettingsXml = ReadRunSettings(commandLineOptions, fileSystem); + + XDocument runSettingsDocument = Patch(runSettingsXml, configuration, client, commandLineOptions); + WarnOnUnsupportedEntries(runSettingsDocument, messageLogger); + + SettingsXml = runSettingsDocument.ToString(); + } + + public string? SettingsXml { get; } + + // Not used by MSTest (matches the bridge's RunSettingsAdapter). + public ISettingsProvider? GetSettings(string? settingsName) => throw new NotImplementedException(); + + internal static string ReadRunSettings(ICommandLineOptions commandLineOptions, IFileSystem fileSystem) + { + _ = commandLineOptions.TryGetOptionArgumentList(RunSettingsOptionName, out string[]? fileNames); + + // Match the runsettings environment-variable provider (which reads the same --settings option): use the + // first provided path rather than requiring exactly one, so a valid value is not ignored when more than + // one argument is present. + if (fileNames is { Length: > 0 } && fileSystem.ExistFile(fileNames[0])) + { + return fileSystem.ReadAllText(fileNames[0]); + } + + string? envVariableRunSettings = Environment.GetEnvironmentVariable("TESTINGPLATFORM_EXPERIMENTAL_VSTEST_RUNSETTINGS"); + if (!string.IsNullOrEmpty(envVariableRunSettings)) + { + return envVariableRunSettings; + } + + string? runSettingsFilePath = Environment.GetEnvironmentVariable("TESTINGPLATFORM_VSTESTBRIDGE_RUNSETTINGS_FILE"); + return !string.IsNullOrEmpty(runSettingsFilePath) && fileSystem.ExistFile(runSettingsFilePath) + ? fileSystem.ReadAllText(runSettingsFilePath) + : string.Empty; + } + + private static XDocument Patch(string? runSettingsXml, IConfiguration configuration, IClientInfo client, ICommandLineOptions commandLineOptions) + { + XDocument runSettingsDocument = PatchSettingsWithDefaults(runSettingsXml, isDesignMode: client.Id == WellKnownClients.VisualStudio, configuration); + PatchTestRunParameters(runSettingsDocument, commandLineOptions); + return runSettingsDocument; + } + + private static XDocument PatchSettingsWithDefaults(string? runSettingsXml, bool isDesignMode, IConfiguration configuration) + { + if (string.IsNullOrWhiteSpace(runSettingsXml)) + { + runSettingsXml = """ + + + """; + } + + var document = XDocument.Parse(runSettingsXml); + if (document.Element("RunSettings") is not { } runSettingsElement) + { + throw new InvalidOperationException(ExtensionResources.MissingRunSettingsAttribute); + } + + bool isPatchingCommentAdded = false; + if (runSettingsElement.Element("RunConfiguration") is not { } runConfigurationElement) + { + runConfigurationElement = new XElement("RunConfiguration"); + AddPatchingCommentIfNeeded(runConfigurationElement, ref isPatchingCommentAdded); + runSettingsElement.AddFirst(runConfigurationElement); + } + + if (runConfigurationElement.Element("DesignMode") is null) + { + AddPatchingCommentIfNeeded(runConfigurationElement, ref isPatchingCommentAdded); + runConfigurationElement.Add(new XElement("DesignMode", isDesignMode)); + } + + if (runConfigurationElement.Element("CollectSourceInformation") is null) + { + AddPatchingCommentIfNeeded(runConfigurationElement, ref isPatchingCommentAdded); + runConfigurationElement.Add(new XElement("CollectSourceInformation", false)); + } + + if (runConfigurationElement.Element("ResultsDirectory") is null) + { + AddPatchingCommentIfNeeded(runConfigurationElement, ref isPatchingCommentAdded); + runConfigurationElement.Add(new XElement("ResultsDirectory", configuration.GetTestResultDirectory())); + } + + if (isPatchingCommentAdded) + { + runConfigurationElement.Add(new XComment("End")); + } + + return document; + } + + private static void AddPatchingCommentIfNeeded(XElement element, ref bool isPatchingCommentAdded) + { + if (isPatchingCommentAdded) + { + return; + } + + element.Add(new XComment("Default configuration added by Microsoft Testing Platform")); + isPatchingCommentAdded = true; + } + + private static void PatchTestRunParameters(XDocument runSettingsDocument, ICommandLineOptions commandLineOptions) + { + if (!commandLineOptions.TryGetOptionArgumentList(TestRunParameterOptionName, out string[]? testRunParameters)) + { + return; + } + + XElement runSettingsElement = runSettingsDocument.Element("RunSettings")!; + XElement? testRunParametersElement = runSettingsElement.Element("TestRunParameters"); + if (testRunParametersElement is null) + { + testRunParametersElement = new XElement("TestRunParameters"); + runSettingsElement.Add(testRunParametersElement); + } + + XElement[] testRunParametersNodes = [.. testRunParametersElement.Nodes().OfType()]; + foreach (string testRunParameter in testRunParameters) + { + string[] parts = testRunParameter.Split(TestRunParameterSeparator, 2); + string name = parts[0]; + string value = parts[1]; + XElement? existingElement = testRunParametersNodes.FirstOrDefault(x => x.Attribute("name")?.Value == name); + if (existingElement is not null) + { + existingElement.Attribute("value")!.Value = value; + } + else + { + var parameterElement = new XElement("Parameter"); + parameterElement.Add(new XAttribute("name", name)); + parameterElement.Add(new XAttribute("value", value)); + testRunParametersElement.Add(parameterElement); + } + } + } + + private static void WarnOnUnsupportedEntries(XDocument document, IMessageLogger messageLogger) + { + XElement runSettingsElement = document.Element("RunSettings")!; + + if (runSettingsElement.Element("LoggerRunSettings") is not null) + { + messageLogger.SendMessage(TestMessageLevel.Warning, ExtensionResources.UnsupportedRunsettingsLoggers); + } + + if (runSettingsElement.Element("DataCollectionRunSettings") is not null) + { + messageLogger.SendMessage(TestMessageLevel.Warning, ExtensionResources.UnsupportedRunsettingsDatacollectors); + } + + if (runSettingsElement.Element("RunConfiguration") is not { } runConfigurationElement) + { + return; + } + + foreach (string unsupportedRunConfigurationSetting in UnsupportedRunConfigurationSettings.Where(setting => runConfigurationElement.Element(setting) is not null)) + { + messageLogger.SendMessage(TestMessageLevel.Warning, string.Format(CultureInfo.InvariantCulture, ExtensionResources.UnsupportedRunconfigurationSetting, unsupportedRunConfigurationSetting)); + } + } +} +#endif diff --git a/src/Adapter/MSTest.TestAdapter/TestingPlatformAdapter/MSTestTestFramework.cs b/src/Adapter/MSTest.TestAdapter/TestingPlatformAdapter/MSTestTestFramework.cs new file mode 100644 index 0000000000..7768de015f --- /dev/null +++ b/src/Adapter/MSTest.TestAdapter/TestingPlatformAdapter/MSTestTestFramework.cs @@ -0,0 +1,225 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +#if !WINDOWS_UWP +using Microsoft.Testing.Extensions.TrxReport.Abstractions; +using Microsoft.Testing.Extensions.VSTestBridge.Capabilities; +using Microsoft.Testing.Extensions.VSTestBridge.Resources; +using Microsoft.Testing.Platform.Capabilities.TestFramework; +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.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. Unlike +/// it does not derive from the VSTest bridge: it handles the MTP discovery/run requests directly, builds the run +/// context, filter and runsettings natively (see / +/// / ), and publishes test nodes through the native +/// / . It reuses MSTest's existing / +/// engine. +/// +/// +/// This is opt-in (behind MSTEST_EXPERIMENTAL_NATIVE_MTP) while the native path reaches full parity with the +/// bridge. It still relies on the bridge only for the shared command-line options (--filter, --settings, +/// --test-parameter) and the runsettings configuration/environment-variable providers; retiring those and the +/// bridge package reference is the final step of the migration. +/// +[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 BridgedConfiguration _configuration; + private readonly ILoggerFactory _loggerFactory; + private readonly CountdownEvent _incomingRequestCounter = new(1); + 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(); + PlatformServiceProvider.Instance.AdapterTraceLogger = new MTPTraceLogger(_loggerFactory.CreateLogger("mstest-trace")); + } + + 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 IInternalVSTestBridgeTrxReportCapability 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, ExtensionResources.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.MessageBus, context.CancellationToken).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); + + await new MSTestDiscoverer(new TestSourceHandler(), CreateTelemetrySender()) + .DiscoverTestsAsync(assemblyPaths, discoveryContext, handle, elementSink, _configuration, isMTP: true) + .ConfigureAwait(false); + } + + private async Task RunTestsAsync(RunTestExecutionRequest request, IMessageBus messageBus, CancellationToken cancellationToken) + { + if (Environment.GetEnvironmentVariable("MSTEST_DEBUG_RUNTESTS") == "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 runContext = new MSTestRunContext(_serviceProvider.GetCommandLineOptions(), runSettings, request.Filter); + + await new MSTestExecutor(cancellationToken, CreateTelemetrySender()) + .RunTestsAsync( + assemblyPaths, + runContext, + handle, + settings => new MtpTestResultRecorder(messageBus, this, sessionUid, IsTrxEnabled, settings), + _configuration, + isMTP: true) + .ConfigureAwait(false); + } + + 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); + } + + public void Dispose() + { + if (!_isDisposed) + { + _incomingRequestCounter.Dispose(); + _isDisposed = true; + } + } +} +#endif diff --git a/src/Adapter/MSTest.TestAdapter/TestingPlatformAdapter/TestApplicationBuilderExtensions.cs b/src/Adapter/MSTest.TestAdapter/TestingPlatformAdapter/TestApplicationBuilderExtensions.cs index 9fb5873961..eda3811103 100644 --- a/src/Adapter/MSTest.TestAdapter/TestingPlatformAdapter/TestApplicationBuilderExtensions.cs +++ b/src/Adapter/MSTest.TestAdapter/TestingPlatformAdapter/TestApplicationBuilderExtensions.cs @@ -43,12 +43,20 @@ public static void AddMSTest(this ITestApplicationBuilder testApplicationBuilder testApplicationBuilder.AddTestRunParametersService(extension); testApplicationBuilder.AddMaximumFailedTestsService(extension); testApplicationBuilder.AddRunSettingsEnvironmentVariableProvider(extension); + + // When the experimental native MTP integration is enabled, MSTest plugs into Microsoft.Testing.Platform + // directly (no VSTest bridge object model on the request path). Off by default, so shipping behavior is + // the bridged framework. + bool useNativeMtp = Environment.GetEnvironmentVariable("MSTEST_EXPERIMENTAL_NATIVE_MTP") is "1" or "true" or "True"; + testApplicationBuilder.RegisterTestFramework( serviceProvider => new TestFrameworkCapabilities( new MSTestCapabilities(), new MSTestBannerCapability(serviceProvider.GetRequiredService()), MSTestGracefulStopTestExecutionCapability.Instance), - (capabilities, serviceProvider) => new MSTestBridgedTestFramework(extension, getTestAssemblies, serviceProvider, capabilities)); + (capabilities, serviceProvider) => useNativeMtp + ? new MSTestTestFramework(extension, getTestAssemblies, serviceProvider, capabilities) + : new MSTestBridgedTestFramework(extension, getTestAssemblies, serviceProvider, capabilities)); } } #endif diff --git a/src/Platform/Microsoft.Testing.Extensions.VSTestBridge/SynchronizedSingleSessionVSTestAndTestAnywhereAdapter.cs b/src/Platform/Microsoft.Testing.Extensions.VSTestBridge/SynchronizedSingleSessionVSTestAndTestAnywhereAdapter.cs index 8ae1478a1f..cdb5362e76 100644 --- a/src/Platform/Microsoft.Testing.Extensions.VSTestBridge/SynchronizedSingleSessionVSTestAndTestAnywhereAdapter.cs +++ b/src/Platform/Microsoft.Testing.Extensions.VSTestBridge/SynchronizedSingleSessionVSTestAndTestAnywhereAdapter.cs @@ -23,6 +23,7 @@ public abstract class SynchronizedSingleSessionVSTestBridgedTestFramework : VSTe private readonly Func> _getTestAssemblies; private readonly CountdownEvent _incomingRequestCounter = new(1); private bool _isDisposed; + private SessionUid? _sessionUid; /// /// Initializes a new instance of the class. @@ -51,13 +52,6 @@ protected SynchronizedSingleSessionVSTestBridgedTestFramework(IExtension extensi /// public sealed override string Version => _extension.Version; - /// - /// Gets the current test session UID, or when no session is active. Exposed to the - /// (internals-visible) MSTest adapter so it can publish Microsoft.Testing.Platform test node updates natively - /// without routing through the VSTest bridge object model. - /// - internal SessionUid? SessionUid { get; private set; } - /// public override async Task IsEnabledAsync() => await _extension.IsEnabledAsync().ConfigureAwait(false); @@ -66,12 +60,12 @@ public sealed override Task CreateTestSessionAsync(Crea { context.CancellationToken.ThrowIfCancellationRequested(); - if (SessionUid is not null) + if (_sessionUid is not null) { - throw new InvalidOperationException(string.Format(CultureInfo.CurrentCulture, ExtensionResources.VSTestBridgedTestFrameworkSessionAlreadyCreatedErrorMessage, SessionUid.Value.Value)); + throw new InvalidOperationException(string.Format(CultureInfo.CurrentCulture, ExtensionResources.VSTestBridgedTestFrameworkSessionAlreadyCreatedErrorMessage, _sessionUid.Value.Value)); } - SessionUid = context.SessionUid; + _sessionUid = context.SessionUid; return Task.FromResult(new CreateTestSessionResult { IsSuccess = true }); } @@ -83,7 +77,7 @@ public sealed override async Task CloseTestSessionAsync( // Wait for remaining request processing await _incomingRequestCounter.WaitAsync(context.CancellationToken).ConfigureAwait(false); - SessionUid = null; + _sessionUid = null; return new CloseTestSessionResult { IsSuccess = true }; }