Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/Adapter/MSTest.TestAdapter/MSTest.TestAdapter.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -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. -->
<PackageReference Include="Microsoft.TestPlatform.ObjectModel" Condition=" '$(TargetFramework)' == '$(UwpMinimum)' " />

<!-- The native Microsoft.Testing.Platform integration (TestingPlatformAdapter\MSTestFilterContext, guarded by
!WINDOWS_UWP) parses VSTest filter expressions via the Filter source package. It flows in transitively via
the VSTestBridge today (PrivateAssets=all there), so reference it explicitly where the native path builds. -->
<PackageReference Include="Microsoft.TestPlatform.Filter.Source" PrivateAssets="all" Condition=" '$(TargetFramework)' != '$(UwpMinimum)' " />
</ItemGroup>

<ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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);
}

/// <inheritdoc />
Expand All @@ -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);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// A native Microsoft.Testing.Platform (MTP) filter context for MSTest. It builds a VSTest
/// <see cref="ITestCaseFilterExpression"/> (which MSTest's <c>TestMethodFilter</c> evaluates over
/// <c>UnitTestElement</c>s) directly from the MTP <see cref="ITestExecutionFilter"/>, the <c>--filter</c>
/// command-line option and the runsettings <c>&lt;TestCaseFilter&gt;</c> — without going through the VSTest bridge's
/// context adapters.
/// </summary>
/// <remarks>
/// This mirrors, for the MSTest native path, what <c>ContextAdapterBase</c> / <c>RunContextAdapter</c> /
/// <c>DiscoveryContextAdapter</c> do in <c>Microsoft.Testing.Extensions.VSTestBridge</c>. The filter-expression
/// parsing itself reuses the VSTest <c>Microsoft.TestPlatform.Filter.Source</c> package, exactly like the bridge.
/// </remarks>
[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<string, object?> 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<string>? supportedProperties,
Func<string, TestProperty?> 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=");
Comment thread
Evangelink marked this conversation as resolved.
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;
}
}
}
}
}

/// <summary>
/// Native MTP implementation of the VSTest <see cref="IRunContext"/> for the MSTest native path.
/// </summary>
[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; }
}

/// <summary>
/// Native MTP implementation of the VSTest <see cref="IDiscoveryContext"/> for the MSTest native path.
/// </summary>
[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
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// A native Microsoft.Testing.Platform (MTP) <see cref="IFrameworkHandle"/> for the MSTest native path. It only
/// forwards diagnostic messages to the MTP <see cref="IOutputDevice"/>; test results are reported natively through
/// the <c>MtpTestResultRecorder</c>, so the VSTest result-recording members are intentionally no-ops.
/// </summary>
[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<string, string?>? 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<AttachmentSet> 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();
}
Comment thread
Evangelink marked this conversation as resolved.

Task<bool> IExtension.IsEnabledAsync() => _extension.IsEnabledAsync();
}
#endif
Loading