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 };
}