Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;

using Jsonite;

using Microsoft.VisualStudio.TestPlatform.ObjectModel;
using Microsoft.VisualStudio.TestPlatform.ObjectModel.Logging;

namespace Microsoft.VisualStudio.TestPlatform.CrossPlatEngine.Client.MTP;
Expand Down Expand Up @@ -54,6 +57,75 @@ public static TimeSpan GetConnectionTimeout()
return TimeSpan.FromSeconds(90);
}

/// <summary>
/// Runs a single MTP discovery pass against <paramref name="source"/> and returns the discovered tests.
/// Shared by the discovery proxy (which forwards the tests to the discovery handler) and the execution
/// proxy (which resolves a <c>/TestCaseFilter</c> against the returned set). Discovery is started with no
/// environment variables so execution-only data-collector profiler variables are never injected.
/// </summary>
/// <param name="source">The MTP application to discover.</param>
/// <param name="logHandler">Receives log messages produced by the MTP application.</param>
/// <param name="cancellationToken">Cancels the discovery pass.</param>
public static List<TestCase> DiscoverSourceTests(
string source,
Action<TestMessageLevel, string?> logHandler,
CancellationToken cancellationToken)
{
var discovered = new List<TestCase>();
using var completed = new ManualResetEventSlim(false);

using var connection = new MtpServerConnection();
connection.LogReceived += (level, message) => logHandler(MapLevel(level), message);
connection.TestNodesUpdated += parameters =>
{
if (IsCompletionSentinel(parameters))
{
completed.Set();
return;
}

foreach (JsonObject node in EnumerateNodes(parameters))
{
if (MtpTestNodeConverter.IsActionNode(node))
{
lock (discovered)
{
discovered.Add(MtpTestNodeConverter.ToTestCase(node, source));
}
}
}
};

connection.Start(source, environmentVariables: null, GetConnectionTimeout());
connection.InvokeAsync(MtpConstants.InitializeMethod, InitializeParameters(), cancellationToken).GetAwaiter().GetResult();

var runId = Guid.NewGuid();
var discoverTask = connection.InvokeAsync(
MtpConstants.DiscoverTestsMethod,
new Dictionary<string, object?> { [MtpConstants.RunIdParameter] = runId.ToString() },
cancellationToken);

// The DiscoverTests response indicates the server finished discovery. Because messages arrive on a
// single ordered stream that we read sequentially, every node notification sent before the response
// has already been dispatched, so 'discovered' is complete once the response returns. Wait briefly
// for the trailing completion sentinel (honoring cancellation) purely to drain it; not observing it
// does not invalidate the discovered set.
discoverTask.GetAwaiter().GetResult();
if (!completed.Wait(TimeSpan.FromSeconds(3), cancellationToken))
{
EqtTrace.Warning(
"MtpClientHelpers.DiscoverSourceTests: discovery for '{0}' did not signal the completion sentinel within the drain window; results reflect the nodes received so far.",
source);
}

connection.SendNotification(MtpConstants.ExitMethod, null);

lock (discovered)
{
return discovered.ToList();
}
}

private static int GetCurrentProcessId()
{
using var process = Process.GetCurrentProcess();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@
using System.Linq;
using System.Threading;

using Jsonite;

using Microsoft.VisualStudio.TestPlatform.ObjectModel;
using Microsoft.VisualStudio.TestPlatform.ObjectModel.Client;
using Microsoft.VisualStudio.TestPlatform.ObjectModel.Engine;
Expand Down Expand Up @@ -84,58 +82,13 @@ public void Dispose()

private int DiscoverSource(string source, ITestDiscoveryEventsHandler2 eventHandler)
{
var discovered = new List<TestCase>();
var completed = new ManualResetEventSlim(false);

using var connection = new MtpServerConnection();
connection.LogReceived += (level, message) => eventHandler.HandleLogMessage(MtpClientHelpers.MapLevel(level), message);
connection.TestNodesUpdated += parameters =>
{
if (MtpClientHelpers.IsCompletionSentinel(parameters))
{
completed.Set();
return;
}

foreach (JsonObject node in MtpClientHelpers.EnumerateNodes(parameters))
{
if (MtpTestNodeConverter.IsActionNode(node))
{
lock (discovered)
{
discovered.Add(MtpTestNodeConverter.ToTestCase(node, source));
}
}
}
};

connection.Start(source, environmentVariables: null, MtpClientHelpers.GetConnectionTimeout());
connection.InvokeAsync(MtpConstants.InitializeMethod, MtpClientHelpers.InitializeParameters(), _cancellationTokenSource.Token).GetAwaiter().GetResult();

var runId = Guid.NewGuid();
var discoverTask = connection.InvokeAsync(
MtpConstants.DiscoverTestsMethod,
new Dictionary<string, object?> { [MtpConstants.RunIdParameter] = runId.ToString() },
_cancellationTokenSource.Token);

// The response indicates the server has finished discovery. Because messages arrive on a
// single ordered stream that we read sequentially, every node notification sent before the
// response has already been dispatched by the time the response completes.
discoverTask.GetAwaiter().GetResult();
completed.Wait(TimeSpan.FromSeconds(3));

List<TestCase> chunk;
lock (discovered)
{
chunk = discovered.ToList();
}
List<TestCase> chunk = MtpClientHelpers.DiscoverSourceTests(source, eventHandler.HandleLogMessage, _cancellationTokenSource.Token);

if (chunk.Count > 0)
{
eventHandler.HandleDiscoveredTests(chunk);
}

connection.SendNotification(MtpConstants.ExitMethod, null);
return chunk.Count;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

using Jsonite;

using Microsoft.VisualStudio.TestPlatform.Common.Filtering;
using Microsoft.VisualStudio.TestPlatform.CrossPlatEngine.Client;
using Microsoft.VisualStudio.TestPlatform.CrossPlatEngine.DataCollection;
using Microsoft.VisualStudio.TestPlatform.CrossPlatEngine.DataCollection.Interfaces;
Expand Down Expand Up @@ -98,7 +99,28 @@ public int StartTestRun(TestRunCriteria testRunCriteria, IInternalTestRunEventsH

try
{
processId = RunSource(source, tests, eventHandler, aggregate, attachments, executorUris);
List<TestCase>? testsToRun = tests;

// A /TestCaseFilter run arrives as sources with no specific tests, so MTP has no notion of
// the vstest filter expression. Discover the source, evaluate the expression against the
// discovered tests (honoring traits and boolean operators exactly like the classic path)
// and run only the matching test-node uids. Without this the filter is silently ignored and
// the whole suite runs.
if (tests is null && !string.IsNullOrEmpty(testRunCriteria.TestCaseFilter))
{
testsToRun = DiscoverAndFilter(source, testRunCriteria.TestCaseFilter!, testRunCriteria.FilterOptions, eventHandler);

// The filter matched nothing for this source. Skip the source entirely: RunSource
// cannot express "run zero tests" — it only sends the MTP tests filter when the list
// has entries and otherwise omits it, which MTP treats as "run every test". So calling
// RunSource with an empty list would run the whole suite; the continue avoids that.
if (testsToRun.Count == 0)
{
continue;
}
Comment on lines +113 to +120
}

processId = RunSource(source, testsToRun, eventHandler, aggregate, attachments, executorUris);
}
catch (OperationCanceledException)
{
Expand Down Expand Up @@ -379,6 +401,111 @@ private int RunSource(
.Select(source => (source, (List<TestCase>?)null));
}

/// <summary>
/// Discovers the tests in <paramref name="source"/> over MTP and returns only those matching the
/// vstest <paramref name="filter"/> expression, so a filtered run executes exactly the selected tests.
/// </summary>
private List<TestCase> DiscoverAndFilter(string source, string filter, FilterOptions? filterOptions, IInternalTestRunEventsHandler eventHandler)
{
var filterWrapper = new FilterExpressionWrapper(filter, filterOptions);
if (!string.IsNullOrEmpty(filterWrapper.ParseError))
{
throw new ObjectModel.Adapter.TestPlatformFormatException(filterWrapper.ParseError, filter);
}

var filterExpression = new TestCaseFilterExpression(filterWrapper);

List<TestCase> discovered = MtpClientHelpers.DiscoverSourceTests(source, eventHandler.HandleLogMessage, _cancellationTokenSource.Token);

var matched = new List<TestCase>();
Comment on lines +416 to +420
foreach (TestCase testCase in discovered)
{
if (filterExpression.MatchTestCase(testCase, BuildPropertyProvider(testCase)))
{
matched.Add(testCase);
}
}

return matched;
}

/// <summary>
/// Builds the property-value lookup a <see cref="TestCaseFilterExpression"/> uses to evaluate a filter
/// against a single <see cref="TestCase"/>. Every property carried on the test case (e.g.
/// FullyQualifiedName, DisplayName, Source, CodeFilePath, ...) is exposed by its label, plus the
/// <c>Name</c> alias for DisplayName and every trait (so filters such as <c>TestCategory=Fast</c>,
/// <c>Priority=1</c> or <c>Source=...</c> behave like they do on the classic path).
/// </summary>
private static Func<string, object?> BuildPropertyProvider(TestCase testCase)
{
var properties = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);

void Add(string key, string? value)
{
if (string.IsNullOrEmpty(key) || value is null)
{
return;
}

if (!properties.TryGetValue(key, out List<string>? values))
{
values = new List<string>();
properties[key] = values;
}

values.Add(value);
}

// Expose all registered properties on the test case by their filter label, so filters can match
// against any property the converter populated (FullyQualifiedName, DisplayName, Source,
// CodeFilePath, LineNumber, ...) rather than a hard-coded subset that silently evaluates to
// "no value" for everything else.
foreach (TestProperty property in testCase.Properties)
{
object? value = testCase.GetPropertyValue(property);
switch (value)
{
case null:
break;
case string[] multiValue:
foreach (string item in multiValue)
{
Add(property.Label, item);
}

break;
default:
Add(property.Label, value.ToString());
break;
}
}

// "Name" is the vstest filter alias for the display name; ensure both are always present even if
// the property store labelled them differently.
string displayName = testCase.DisplayName ?? testCase.FullyQualifiedName;
if (!properties.ContainsKey("FullyQualifiedName"))
{
Add("FullyQualifiedName", testCase.FullyQualifiedName);
}

if (!properties.ContainsKey("DisplayName"))
{
Add("DisplayName", displayName);
}

Add("Name", displayName);

// Traits (TestCategory, Priority, custom) are matched by trait name.
foreach (Trait trait in testCase.Traits)
{
Add(trait.Name, trait.Value);
}

return name => properties.TryGetValue(name, out List<string>? values)
? (values.Count == 1 ? values[0] : values.ToArray())
: null;
}

/// <summary>
/// Reads the environment variables declared in the runsettings
/// <c>RunConfiguration/EnvironmentVariables</c> and merges them into <see cref="EnvironmentVariables"/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -187,12 +187,61 @@ public void RunMtpApplicationSurfacesPerTestStandardOutput(RunnerInfo runnerInfo

InvokeVsTest(arguments);

ValidateSummaryStatus(2, 1, 1);
// The asset runs 5 tests (TestPasses, TestPassesToo, the runsettings-env no-op, TestFails, TestSkipped).
ValidateSummaryStatus(3, 1, 1);

var trxPath = Path.Combine(TempDirectory.Path, trxFileName);
Assert.IsTrue(File.Exists(trxPath), "Expected a TRX at '{0}'.", trxPath);
var trx = File.ReadAllText(trxPath);
Assert.Contains("MTP_STDOUT_MARKER", trx, "Expected the test's standard output to be surfaced into the TRX.");
Assert.Contains("MTP_STDERR_MARKER", trx, "Expected the test's standard error to be surfaced into the TRX.");
}

[TestMethod]
// /TestCaseFilter must scope an MTP run just like it does on the classic path. MTP has no notion of the
// vstest filter expression, so vstest.console discovers the app, evaluates the expression against the
// discovered tests and runs only the matching test-node uids. Before this the filter was silently
// ignored and the whole suite ran (X/N/N) where N>0. The filter here selects only the X passing tests.
[TestMatrix(testHost: Target.Net)]
public void RunMtpApplicationHonorsTestCaseFilter(RunnerInfo runnerInfo)
{
Comment thread
azat-msft marked this conversation as resolved.
SetTestEnvironment(_testEnvironment, runnerInfo);

var arguments = PrepareArguments(
GetAssetFullPath(MtpApp),
testAdapterPath: null,
runSettings: string.Empty,
FrameworkArgValue,
runnerInfo.InIsolationValue,
resultsDirectory: TempDirectory.Path);
arguments = string.Concat(arguments, " /TestCaseFilter:\"DisplayName~TestPasses\"");

InvokeVsTest(arguments);

// Only TestPasses and TestPassesToo match; TestFails and TestSkipped are excluded by the filter.
ValidateSummaryStatus(2, 0, 0);
}

[TestMethod]
// A /TestCaseFilter that matches nothing must run zero tests on the MTP path, not fall back to running
// the whole suite. This guards the regression the feature targets: before, a non-matching filter was
// silently ignored and every test ran (N/N/N) where N>0. Here the filter matches no test, so nothing runs.
[TestMatrix(testHost: Target.Net)]
public void RunMtpApplicationHonorsNonMatchingTestCaseFilter(RunnerInfo runnerInfo)
{
SetTestEnvironment(_testEnvironment, runnerInfo);

var arguments = PrepareArguments(
GetAssetFullPath(MtpApp),
testAdapterPath: null,
runSettings: string.Empty,
FrameworkArgValue,
runnerInfo.InIsolationValue,
resultsDirectory: TempDirectory.Path);
arguments = string.Concat(arguments, " /TestCaseFilter:\"DisplayName~NoSuchTestNameMatchesThis\"");

InvokeVsTest(arguments);

ValidateSummaryStatus(0, 0, 0);
}
}