Skip to content
Open
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
2 changes: 2 additions & 0 deletions src/Cli/dotnet/Commands/Test/CliConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -118,4 +118,6 @@ internal static class ProjectProperties
internal const string AppDesignerFolder = "AppDesignerFolder";
internal const string TestTfmsInParallel = "TestTfmsInParallel";
internal const string BuildInParallel = "BuildInParallel";
internal const string IsTraversal = "IsTraversal";
internal const string ProjectReferenceItemName = "ProjectReference";
}
8 changes: 5 additions & 3 deletions src/Cli/dotnet/Commands/Test/MTP/MSBuildUtility.cs
Original file line number Diff line number Diff line change
Expand Up @@ -292,10 +292,12 @@ private static (string? PositionalProjectOrSolution, string? PositionalTestModul
throw new GracefulException(CliCommandStrings.TestCommandUseSolution);
}
}
else if ((token.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase) ||
token.EndsWith(".vbproj", StringComparison.OrdinalIgnoreCase) ||
token.EndsWith(".fsproj", StringComparison.OrdinalIgnoreCase)) && File.Exists(token))
else if (Path.GetExtension(token).EndsWith("proj", StringComparison.OrdinalIgnoreCase) && File.Exists(token))
{
// Any MSBuild project extension ending in "proj" (.csproj, .vbproj, .fsproj, and traversal
// container projects such as dirs.proj / *.proj). This mirrors ValidateProjectOrSolutionPath,
// which accepts any "*proj" extension. Recognizing it here ensures the project path is not
// accidentally forwarded to the test application as an argument.
if (i == 0)
{
positionalProjectOrSolution = token;
Expand Down
87 changes: 85 additions & 2 deletions src/Cli/dotnet/Commands/Test/MTP/SolutionAndProjectUtility.cs
Original file line number Diff line number Diff line change
Expand Up @@ -240,11 +240,41 @@ public static IEnumerable<ParallelizableTestModuleGroupWithSequentialInnerModule
BuildOptions buildOptions,
FacadeLogger? logger,
string? configuration,
string? platform)
string? platform,
HashSet<string>? visitedTraversalProjects = null)
{
var projects = new List<ParallelizableTestModuleGroupWithSequentialInnerModules>();
ProjectInstance projectInstance = EvaluateProject(projectCollection, evaluationContext, projectFilePath, tfm: null, configuration, platform);

// Traversal projects (e.g. Microsoft.Build.Traversal "dirs.proj") are not test projects themselves.
// They act as a container that forwards build/test operations to their ProjectReference items.
// Special-case them the same way solutions are handled: expand into the referenced projects and
// evaluate each of them. This is done recursively so that nested traversal projects work as well.
if (IsTraversalProject(projectInstance))
{
// Track visited (project, configuration, platform) tuples across the whole traversal graph so
// that a project referenced by multiple traversal projects with the same configuration/platform
// (a "diamond") is only tested once, while the same project referenced with a *different*
// configuration/platform is still tested for each distinct combination. This also guards against
// cycles (a traversal project that transitively references itself).
visitedTraversalProjects ??= new HashSet<string>(StringComparer.OrdinalIgnoreCase);
visitedTraversalProjects.Add(GetTraversalVisitKey(Path.GetFullPath(projectFilePath), configuration, platform));

foreach (var reference in GetTraversalReferencedProjects(projectInstance, configuration, platform))
{
if (!visitedTraversalProjects.Add(GetTraversalVisitKey(reference.FullPath, reference.Configuration, reference.Platform)))
{
// Already handled via another traversal path (diamond) or a cycle, with the same
// configuration/platform combination.
continue;
}

projects.AddRange(GetProjectProperties(reference.FullPath, projectCollection, evaluationContext, buildOptions, logger, reference.Configuration, reference.Platform, visitedTraversalProjects));
}

return projects;
}

var targetFramework = projectInstance.GetPropertyValue(ProjectProperties.TargetFramework);
var targetFrameworks = projectInstance.GetPropertyValue(ProjectProperties.TargetFrameworks);

Expand Down Expand Up @@ -312,7 +342,60 @@ public static IEnumerable<ParallelizableTestModuleGroupWithSequentialInnerModule
}

/// <summary>
/// Performs device selection for each TFM BEFORE the build, so that device-provided
/// Determines whether the evaluated project is a traversal project (e.g. a
/// <c>Microsoft.Build.Traversal</c> "dirs.proj"). Traversal projects set the
/// <c>IsTraversal</c> property to <c>true</c> and merely forward operations to their
/// <c>ProjectReference</c> items rather than producing a test module of their own.
/// </summary>
private static bool IsTraversalProject(ProjectInstance projectInstance)
=> bool.TryParse(projectInstance.GetPropertyValue(ProjectProperties.IsTraversal), out bool isTraversal) && isTraversal;

/// <summary>
/// Builds a stable key identifying a (project, configuration, platform) combination for
/// traversal-graph de-duplication and cycle detection.
/// </summary>
private static string GetTraversalVisitKey(string fullPath, string? configuration, string? platform)
=> $"{fullPath}|{configuration}|{platform}";

/// <summary>
/// Returns the projects a traversal project references. The globs and conditions in the traversal
/// project are already expanded by MSBuild during evaluation, so the resolved <c>ProjectReference</c>
/// items represent the effective set of projects to test. Per-reference <c>Configuration</c>/
/// <c>Platform</c> metadata is honored when present (falling back to the values inherited from the
/// traversal project), mirroring how MSBuild lets a <c>ProjectReference</c> target a specific
/// configuration or platform.
/// </summary>
private static IEnumerable<(string FullPath, string? Configuration, string? Platform)> GetTraversalReferencedProjects(
ProjectInstance projectInstance,
string? inheritedConfiguration,
string? inheritedPlatform)
{
// Resolve reference paths relative to the traversal project's directory (not the process working
// directory). MSBuild's "FullPath" well-known metadata is normally already absolute, but resolving
// against the project directory explicitly keeps us correct even if a relative value is returned or
// the current directory differs from the project directory.
var projectDirectory = projectInstance.Directory;

foreach (ProjectItemInstance projectReference in projectInstance.GetItems(ProjectProperties.ProjectReferenceItemName))
{
// "FullPath" is a well-known item metadata that MSBuild computes relative to the project directory.
var fullPath = projectReference.GetMetadataValue("FullPath");
if (string.IsNullOrEmpty(fullPath))
{
continue;
}

var configurationMetadata = projectReference.GetMetadataValue(ProjectProperties.Configuration);
var platformMetadata = projectReference.GetMetadataValue(ProjectProperties.Platform);

yield return (
Path.GetFullPath(fullPath, projectDirectory),
string.IsNullOrEmpty(configurationMetadata) ? inheritedConfiguration : configurationMetadata,
string.IsNullOrEmpty(platformMetadata) ? inheritedPlatform : platformMetadata);
}
}

/// <summary>
/// RuntimeIdentifiers are included in the build. Returns a result with device mappings
/// and TestTfmsInParallel setting, or null if no device selection is needed.
/// When projectCollection/evaluationContext are provided, reuses them to avoid redundant evaluation.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,23 @@ Copyright (c) .NET Foundation. All rights reserved.
<Import Condition="Exists('$(VSTestTargets)')" Project="$(VSTestTargets)" />
<Target Name="_MTPBuild">
<MSBuild Projects="$(MSBuildProjectFullPath)" Condition="'$(IsTestingPlatformApplication)'=='true'" />

<!-- Traversal projects (Microsoft.Build.Traversal) are containers that don't build themselves;
forward the MTP build to their referenced projects so the referenced test projects get built.
Per-reference Configuration/Platform metadata is forwarded as global properties so a reference
that pins a specific configuration/platform builds consistently with the dotnet test evaluation
logic (which reads the same metadata). The values are precomputed here so empty metadata does not
emit a "Configuration=" assignment that would clear the inherited global property; empty segments
and leading/trailing ';' in Properties are ignored by MSBuild. -->
<ItemGroup Condition="'$(IsTraversal)'=='true'">
<_MTPTraversalReference Include="@(ProjectReference)">
<_ConfigurationProperty Condition="'%(ProjectReference.Configuration)' != ''">Configuration=%(ProjectReference.Configuration)</_ConfigurationProperty>
<_PlatformProperty Condition="'%(ProjectReference.Platform)' != ''">Platform=%(ProjectReference.Platform)</_PlatformProperty>
</_MTPTraversalReference>
</ItemGroup>
<MSBuild Projects="@(_MTPTraversalReference)"
Targets="_MTPBuild"
Properties="%(_MTPTraversalReference._ConfigurationProperty);%(_MTPTraversalReference._PlatformProperty)"
Condition="'$(IsTraversal)'=='true'" />
</Target>
</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<Import Project="$([MSBuild]::GetDirectoryNameOfFileAbove($(MSBuildThisFileDirectory), testAsset.props))\testAsset.props" />

<PropertyGroup>
<TargetFramework>$(CurrentTargetFramework)</TargetFramework>
<OutputType>Exe</OutputType>

<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>

<GenerateProgramFile>false</GenerateProgramFile>
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
<IsTestingPlatformApplication>true</IsTestingPlatformApplication>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Testing.Platform" Version="$(MicrosoftTestingPlatformVersion)" />
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using Microsoft.Testing.Platform.Builder;
using Microsoft.Testing.Platform.Capabilities.TestFramework;
using Microsoft.Testing.Platform.Extensions.Messages;
using Microsoft.Testing.Platform.Extensions.TestFramework;

var testApplicationBuilder = await TestApplication.CreateBuilderAsync(args);

testApplicationBuilder.RegisterTestFramework(_ => new TestFrameworkCapabilities(), (_, __) => new DummyTestAdapter());

using var testApplication = await testApplicationBuilder.BuildAsync();
return await testApplication.RunAsync();

public class DummyTestAdapter : ITestFramework, IDataProducer
{
public string Uid => nameof(DummyTestAdapter);

public string Version => "2.0.0";

public string DisplayName => nameof(DummyTestAdapter);

public string Description => nameof(DummyTestAdapter);

public Task<bool> IsEnabledAsync() => Task.FromResult(true);

public Type[] DataTypesProduced => [];

public Task<CreateTestSessionResult> CreateTestSessionAsync(CreateTestSessionContext context)
=> Task.FromResult(new CreateTestSessionResult() { IsSuccess = true });

public Task<CloseTestSessionResult> CloseTestSessionAsync(CloseTestSessionContext context)
=> Task.FromResult(new CloseTestSessionResult() { IsSuccess = true });

public async Task ExecuteRequestAsync(ExecuteRequestContext context)
{
context.Complete();
await Task.CompletedTask;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using Microsoft.Testing.Platform.Builder;
using Microsoft.Testing.Platform.Capabilities.TestFramework;
using Microsoft.Testing.Platform.Extensions.Messages;
using Microsoft.Testing.Platform.Extensions.TestFramework;

var testApplicationBuilder = await TestApplication.CreateBuilderAsync(args);

testApplicationBuilder.RegisterTestFramework(_ => new TestFrameworkCapabilities(), (_, __) => new DummyTestAdapter());

using var testApplication = await testApplicationBuilder.BuildAsync();
return await testApplication.RunAsync();

public class DummyTestAdapter : ITestFramework, IDataProducer
{
public string Uid => nameof(DummyTestAdapter);

public string Version => "2.0.0";

public string DisplayName => nameof(DummyTestAdapter);

public string Description => nameof(DummyTestAdapter);

public Task<bool> IsEnabledAsync() => Task.FromResult(true);

public Type[] DataTypesProduced => [];

public Task<CreateTestSessionResult> CreateTestSessionAsync(CreateTestSessionContext context)
=> Task.FromResult(new CreateTestSessionResult() { IsSuccess = true });

public Task<CloseTestSessionResult> CloseTestSessionAsync(CloseTestSessionContext context)
=> Task.FromResult(new CloseTestSessionResult() { IsSuccess = true });

public async Task ExecuteRequestAsync(ExecuteRequestContext context)
{
context.Complete();
await Task.CompletedTask;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<Import Project="$([MSBuild]::GetDirectoryNameOfFileAbove($(MSBuildThisFileDirectory), testAsset.props))\testAsset.props" />

<PropertyGroup>
<TargetFramework>$(CurrentTargetFramework)</TargetFramework>
<OutputType>Exe</OutputType>

<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>

<GenerateProgramFile>false</GenerateProgramFile>
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
<IsTestingPlatformApplication>true</IsTestingPlatformApplication>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Testing.Platform" Version="$(MicrosoftTestingPlatformVersion)" />
</ItemGroup>
</Project>
6 changes: 6 additions & 0 deletions test/TestAssets/TestProjects/TraversalTestProjects/dirs.proj
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<Project Sdk="Microsoft.Build.Traversal/4.1.82">
<ItemGroup>
<ProjectReference Include="TestProject\TestProject.csproj" />
<ProjectReference Include="OtherTestProject\OtherTestProject.csproj" />
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"test": {
"runner": "Microsoft.Testing.Platform"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<Import Project="$([MSBuild]::GetDirectoryNameOfFileAbove($(MSBuildThisFileDirectory), testAsset.props))\testAsset.props" />

<PropertyGroup>
<TargetFramework>$(CurrentTargetFramework)</TargetFramework>
<OutputType>Exe</OutputType>

<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>

<GenerateProgramFile>false</GenerateProgramFile>
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
<IsTestingPlatformApplication>true</IsTestingPlatformApplication>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Testing.Platform" Version="$(MicrosoftTestingPlatformVersion)" />
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
using System.Reflection;
using Microsoft.Testing.Platform.Builder;
using Microsoft.Testing.Platform.Capabilities.TestFramework;
using Microsoft.Testing.Platform.Extensions.Messages;
using Microsoft.Testing.Platform.Extensions.TestFramework;

// Drop a uniquely-named marker file per process launch so tests can deterministically count how many
// times this test application actually ran (used to validate traversal de-duplication).
var markerDir = Environment.GetEnvironmentVariable("TRAVERSAL_MARKER_DIR");
if (!string.IsNullOrEmpty(markerDir))
{
Directory.CreateDirectory(markerDir);
var assemblyName = Assembly.GetEntryAssembly()?.GetName().Name ?? "unknown";
File.WriteAllText(Path.Combine(markerDir, $"{assemblyName}-{Guid.NewGuid():N}.marker"), assemblyName);
}

var testApplicationBuilder = await TestApplication.CreateBuilderAsync(args);

testApplicationBuilder.RegisterTestFramework(_ => new TestFrameworkCapabilities(), (_, __) => new DummyTestAdapter());

using var testApplication = await testApplicationBuilder.BuildAsync();
return await testApplication.RunAsync();

public class DummyTestAdapter : ITestFramework, IDataProducer
{
public string Uid => nameof(DummyTestAdapter);

public string Version => "2.0.0";

public string DisplayName => nameof(DummyTestAdapter);

public string Description => nameof(DummyTestAdapter);

public Task<bool> IsEnabledAsync() => Task.FromResult(true);

public Type[] DataTypesProduced => [];

public Task<CreateTestSessionResult> CreateTestSessionAsync(CreateTestSessionContext context)
=> Task.FromResult(new CreateTestSessionResult() { IsSuccess = true });

public Task<CloseTestSessionResult> CloseTestSessionAsync(CloseTestSessionContext context)
=> Task.FromResult(new CloseTestSessionResult() { IsSuccess = true });

public async Task ExecuteRequestAsync(ExecuteRequestContext context)
{
context.Complete();
await Task.CompletedTask;
}
}
Loading
Loading