Skip to content
Closed
Show file tree
Hide file tree
Changes from 5 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 @@ -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,172 @@ 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 = DiscoverSourceTests(source, eventHandler);

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>
/// Runs an MTP discovery pass against <paramref name="source"/> and returns the discovered tests. Used
/// to resolve a <c>/TestCaseFilter</c> into a concrete set of tests to run by uid.
/// </summary>
private List<TestCase> DiscoverSourceTests(string source, IInternalTestRunEventsHandler eventHandler)
{
var discovered = new List<TestCase>();
using 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));
}
}
}
};

// Discovery only needs to enumerate the tests to resolve the filter; it must not inject the
// data-collector profiler environment variables (those belong to the execution pass), so start
// with no environment variables, mirroring MtpProxyDiscoveryManager.
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 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 the filter is evaluated against.
discoverTask.GetAwaiter().GetResult();
if (!completed.Wait(TimeSpan.FromSeconds(3), _cancellationTokenSource.Token))
{
EqtTrace.Warning(
"MtpProxyExecutionManager.DiscoverSourceTests: discovery for '{0}' did not signal the completion sentinel within the drain window; the /TestCaseFilter is evaluated against the nodes received so far.",
source);
}

connection.SendNotification(MtpConstants.ExitMethod, null);

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

/// <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.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 @@ -195,4 +195,52 @@ public void RunMtpApplicationSurfacesPerTestStandardOutput(RunnerInfo runnerInfo
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 (2/1/1). The filter here selects only the two passing tests.
[TestMatrix(testHost: Target.Net)]
Comment on lines +201 to +205
public void RunMtpApplicationHonorsTestCaseFilter(RunnerInfo runnerInfo)
{
Comment thread
azat-msft marked this conversation as resolved.
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 (2/1/1). Here the filter matches no test, so nothing runs.
[TestMatrix(testHost: Target.Net)]
Comment on lines +226 to +229
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);
}
}
Loading