Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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 @@ -113,4 +113,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 @@ -291,10 +291,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
71 changes: 69 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,38 @@ public static IEnumerable<ParallelizableTestModuleGroupWithSequentialInnerModule
EvaluationContext evaluationContext,
BuildOptions buildOptions,
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 projects across the whole traversal graph so that a project referenced by
// multiple traversal projects (a "diamond") is only tested once, and to guard against cycles
// (a traversal project that transitively references itself).
visitedTraversalProjects ??= new HashSet<string>(StringComparer.OrdinalIgnoreCase);
visitedTraversalProjects.Add(Path.GetFullPath(projectFilePath));

foreach (var reference in GetTraversalReferencedProjects(projectInstance, configuration, platform))
{
if (!visitedTraversalProjects.Add(reference.FullPath))
{
// Already handled via another traversal path (diamond) or a cycle.
Comment thread
Evangelink marked this conversation as resolved.
Outdated
continue;
}

projects.AddRange(GetProjectProperties(reference.FullPath, projectCollection, evaluationContext, buildOptions, 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 +339,47 @@ 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>
/// 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)
{
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),
string.IsNullOrEmpty(configurationMetadata) ? inheritedConfiguration : configurationMetadata,
string.IsNullOrEmpty(platformMetadata) ? inheritedPlatform : platformMetadata);
Comment thread
Evangelink marked this conversation as resolved.
}
}

/// <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,8 @@ 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. -->
<MSBuild Projects="@(ProjectReference)" Targets="_MTPBuild" Condition="'$(IsTraversal)'=='true'" />
Comment thread
Evangelink marked this conversation as resolved.
Outdated
</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;
}
}
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;
}
}
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>
Loading
Loading