diff --git a/src/Cli/dotnet/Commands/Test/CliConstants.cs b/src/Cli/dotnet/Commands/Test/CliConstants.cs index 6b5113c87241..70fe370a6e5f 100644 --- a/src/Cli/dotnet/Commands/Test/CliConstants.cs +++ b/src/Cli/dotnet/Commands/Test/CliConstants.cs @@ -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"; } diff --git a/src/Cli/dotnet/Commands/Test/MTP/MSBuildUtility.cs b/src/Cli/dotnet/Commands/Test/MTP/MSBuildUtility.cs index a094c37939ab..1d0186bc9d49 100644 --- a/src/Cli/dotnet/Commands/Test/MTP/MSBuildUtility.cs +++ b/src/Cli/dotnet/Commands/Test/MTP/MSBuildUtility.cs @@ -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; diff --git a/src/Cli/dotnet/Commands/Test/MTP/SolutionAndProjectUtility.cs b/src/Cli/dotnet/Commands/Test/MTP/SolutionAndProjectUtility.cs index cbcc8a5d8af4..82ed1935df20 100644 --- a/src/Cli/dotnet/Commands/Test/MTP/SolutionAndProjectUtility.cs +++ b/src/Cli/dotnet/Commands/Test/MTP/SolutionAndProjectUtility.cs @@ -240,11 +240,41 @@ public static IEnumerable? visitedTraversalProjects = null) { var projects = new List(); 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(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, reference.Configuration, reference.Platform, visitedTraversalProjects)); + } + + return projects; + } + var targetFramework = projectInstance.GetPropertyValue(ProjectProperties.TargetFramework); var targetFrameworks = projectInstance.GetPropertyValue(ProjectProperties.TargetFrameworks); @@ -312,7 +342,60 @@ public static IEnumerable - /// 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 + /// Microsoft.Build.Traversal "dirs.proj"). Traversal projects set the + /// IsTraversal property to true and merely forward operations to their + /// ProjectReference items rather than producing a test module of their own. + /// + private static bool IsTraversalProject(ProjectInstance projectInstance) + => bool.TryParse(projectInstance.GetPropertyValue(ProjectProperties.IsTraversal), out bool isTraversal) && isTraversal; + + /// + /// Builds a stable key identifying a (project, configuration, platform) combination for + /// traversal-graph de-duplication and cycle detection. + /// + private static string GetTraversalVisitKey(string fullPath, string? configuration, string? platform) + => $"{fullPath}|{configuration}|{platform}"; + + /// + /// 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 ProjectReference + /// items represent the effective set of projects to test. Per-reference Configuration/ + /// Platform metadata is honored when present (falling back to the values inherited from the + /// traversal project), mirroring how MSBuild lets a ProjectReference target a specific + /// configuration or platform. + /// + 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); + } + } + + /// /// 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. diff --git a/src/Layout/redist/MSBuildImports/Current/Microsoft.Common.targets/ImportAfter/Microsoft.TestPlatform.ImportAfter.targets b/src/Layout/redist/MSBuildImports/Current/Microsoft.Common.targets/ImportAfter/Microsoft.TestPlatform.ImportAfter.targets index 001e4afef5d3..d2326b2c91a0 100644 --- a/src/Layout/redist/MSBuildImports/Current/Microsoft.Common.targets/ImportAfter/Microsoft.TestPlatform.ImportAfter.targets +++ b/src/Layout/redist/MSBuildImports/Current/Microsoft.Common.targets/ImportAfter/Microsoft.TestPlatform.ImportAfter.targets @@ -19,5 +19,23 @@ Copyright (c) .NET Foundation. All rights reserved. + + + + <_MTPTraversalReference Include="@(ProjectReference)"> + <_ConfigurationProperty Condition="'%(ProjectReference.Configuration)' != ''">Configuration=%(ProjectReference.Configuration) + <_PlatformProperty Condition="'%(ProjectReference.Platform)' != ''">Platform=%(ProjectReference.Platform) + + + diff --git a/test/TestAssets/TestProjects/TraversalTestProjects/OtherTestProject/OtherTestProject.csproj b/test/TestAssets/TestProjects/TraversalTestProjects/OtherTestProject/OtherTestProject.csproj new file mode 100644 index 000000000000..7d8bdc9b174b --- /dev/null +++ b/test/TestAssets/TestProjects/TraversalTestProjects/OtherTestProject/OtherTestProject.csproj @@ -0,0 +1,19 @@ + + + + + $(CurrentTargetFramework) + Exe + + enable + enable + + false + false + true + + + + + + diff --git a/test/TestAssets/TestProjects/TraversalTestProjects/OtherTestProject/Program.cs b/test/TestAssets/TestProjects/TraversalTestProjects/OtherTestProject/Program.cs new file mode 100644 index 000000000000..bfce4d33d25a --- /dev/null +++ b/test/TestAssets/TestProjects/TraversalTestProjects/OtherTestProject/Program.cs @@ -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 IsEnabledAsync() => Task.FromResult(true); + + public Type[] DataTypesProduced => []; + + public Task CreateTestSessionAsync(CreateTestSessionContext context) + => Task.FromResult(new CreateTestSessionResult() { IsSuccess = true }); + + public Task CloseTestSessionAsync(CloseTestSessionContext context) + => Task.FromResult(new CloseTestSessionResult() { IsSuccess = true }); + + public async Task ExecuteRequestAsync(ExecuteRequestContext context) + { + context.Complete(); + await Task.CompletedTask; + } +} diff --git a/test/TestAssets/TestProjects/TraversalTestProjects/TestProject/Program.cs b/test/TestAssets/TestProjects/TraversalTestProjects/TestProject/Program.cs new file mode 100644 index 000000000000..bfce4d33d25a --- /dev/null +++ b/test/TestAssets/TestProjects/TraversalTestProjects/TestProject/Program.cs @@ -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 IsEnabledAsync() => Task.FromResult(true); + + public Type[] DataTypesProduced => []; + + public Task CreateTestSessionAsync(CreateTestSessionContext context) + => Task.FromResult(new CreateTestSessionResult() { IsSuccess = true }); + + public Task CloseTestSessionAsync(CloseTestSessionContext context) + => Task.FromResult(new CloseTestSessionResult() { IsSuccess = true }); + + public async Task ExecuteRequestAsync(ExecuteRequestContext context) + { + context.Complete(); + await Task.CompletedTask; + } +} diff --git a/test/TestAssets/TestProjects/TraversalTestProjects/TestProject/TestProject.csproj b/test/TestAssets/TestProjects/TraversalTestProjects/TestProject/TestProject.csproj new file mode 100644 index 000000000000..7d8bdc9b174b --- /dev/null +++ b/test/TestAssets/TestProjects/TraversalTestProjects/TestProject/TestProject.csproj @@ -0,0 +1,19 @@ + + + + + $(CurrentTargetFramework) + Exe + + enable + enable + + false + false + true + + + + + + diff --git a/test/TestAssets/TestProjects/TraversalTestProjects/dirs.proj b/test/TestAssets/TestProjects/TraversalTestProjects/dirs.proj new file mode 100644 index 000000000000..13ed68fc7263 --- /dev/null +++ b/test/TestAssets/TestProjects/TraversalTestProjects/dirs.proj @@ -0,0 +1,6 @@ + + + + + + diff --git a/test/TestAssets/TestProjects/TraversalTestProjects/global.json b/test/TestAssets/TestProjects/TraversalTestProjects/global.json new file mode 100644 index 000000000000..9009caf0ba8f --- /dev/null +++ b/test/TestAssets/TestProjects/TraversalTestProjects/global.json @@ -0,0 +1,5 @@ +{ + "test": { + "runner": "Microsoft.Testing.Platform" + } +} diff --git a/test/TestAssets/TestProjects/TraversalTestProjectsNested/LeafTestProject/LeafTestProject.csproj b/test/TestAssets/TestProjects/TraversalTestProjectsNested/LeafTestProject/LeafTestProject.csproj new file mode 100644 index 000000000000..7d8bdc9b174b --- /dev/null +++ b/test/TestAssets/TestProjects/TraversalTestProjectsNested/LeafTestProject/LeafTestProject.csproj @@ -0,0 +1,19 @@ + + + + + $(CurrentTargetFramework) + Exe + + enable + enable + + false + false + true + + + + + + diff --git a/test/TestAssets/TestProjects/TraversalTestProjectsNested/LeafTestProject/Program.cs b/test/TestAssets/TestProjects/TraversalTestProjectsNested/LeafTestProject/Program.cs new file mode 100644 index 000000000000..3e4544e6169a --- /dev/null +++ b/test/TestAssets/TestProjects/TraversalTestProjectsNested/LeafTestProject/Program.cs @@ -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 IsEnabledAsync() => Task.FromResult(true); + + public Type[] DataTypesProduced => []; + + public Task CreateTestSessionAsync(CreateTestSessionContext context) + => Task.FromResult(new CreateTestSessionResult() { IsSuccess = true }); + + public Task CloseTestSessionAsync(CloseTestSessionContext context) + => Task.FromResult(new CloseTestSessionResult() { IsSuccess = true }); + + public async Task ExecuteRequestAsync(ExecuteRequestContext context) + { + context.Complete(); + await Task.CompletedTask; + } +} diff --git a/test/TestAssets/TestProjects/TraversalTestProjectsNested/SharedTestProject/Program.cs b/test/TestAssets/TestProjects/TraversalTestProjectsNested/SharedTestProject/Program.cs new file mode 100644 index 000000000000..3e4544e6169a --- /dev/null +++ b/test/TestAssets/TestProjects/TraversalTestProjectsNested/SharedTestProject/Program.cs @@ -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 IsEnabledAsync() => Task.FromResult(true); + + public Type[] DataTypesProduced => []; + + public Task CreateTestSessionAsync(CreateTestSessionContext context) + => Task.FromResult(new CreateTestSessionResult() { IsSuccess = true }); + + public Task CloseTestSessionAsync(CloseTestSessionContext context) + => Task.FromResult(new CloseTestSessionResult() { IsSuccess = true }); + + public async Task ExecuteRequestAsync(ExecuteRequestContext context) + { + context.Complete(); + await Task.CompletedTask; + } +} diff --git a/test/TestAssets/TestProjects/TraversalTestProjectsNested/SharedTestProject/SharedTestProject.csproj b/test/TestAssets/TestProjects/TraversalTestProjectsNested/SharedTestProject/SharedTestProject.csproj new file mode 100644 index 000000000000..7d8bdc9b174b --- /dev/null +++ b/test/TestAssets/TestProjects/TraversalTestProjectsNested/SharedTestProject/SharedTestProject.csproj @@ -0,0 +1,19 @@ + + + + + $(CurrentTargetFramework) + Exe + + enable + enable + + false + false + true + + + + + + diff --git a/test/TestAssets/TestProjects/TraversalTestProjectsNested/dirs.proj b/test/TestAssets/TestProjects/TraversalTestProjectsNested/dirs.proj new file mode 100644 index 000000000000..14e9d7986baf --- /dev/null +++ b/test/TestAssets/TestProjects/TraversalTestProjectsNested/dirs.proj @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/test/TestAssets/TestProjects/TraversalTestProjectsNested/global.json b/test/TestAssets/TestProjects/TraversalTestProjectsNested/global.json new file mode 100644 index 000000000000..9009caf0ba8f --- /dev/null +++ b/test/TestAssets/TestProjects/TraversalTestProjectsNested/global.json @@ -0,0 +1,5 @@ +{ + "test": { + "runner": "Microsoft.Testing.Platform" + } +} diff --git a/test/TestAssets/TestProjects/TraversalTestProjectsNested/sub/dirs.proj b/test/TestAssets/TestProjects/TraversalTestProjectsNested/sub/dirs.proj new file mode 100644 index 000000000000..15a7c61de2bd --- /dev/null +++ b/test/TestAssets/TestProjects/TraversalTestProjectsNested/sub/dirs.proj @@ -0,0 +1,6 @@ + + + + + + diff --git a/test/dotnet.Tests/CommandTests/Test/GivenDotnetTestBuildsAndRunsTests.cs b/test/dotnet.Tests/CommandTests/Test/GivenDotnetTestBuildsAndRunsTests.cs index be5adca9ad7a..680cf6303d47 100644 --- a/test/dotnet.Tests/CommandTests/Test/GivenDotnetTestBuildsAndRunsTests.cs +++ b/test/dotnet.Tests/CommandTests/Test/GivenDotnetTestBuildsAndRunsTests.cs @@ -411,6 +411,62 @@ public void RunOnMultipleProjectFoldersWithoutSolutionFile_ShouldReturnExitCodeG result.ExitCode.Should().Be(ExitCodes.GenericFailure); } + [DataRow(TestingConstants.Debug)] + [DataRow(TestingConstants.Release)] + [TestMethod] + public void RunTraversalProject_ShouldRunReferencedTestProjects(string configuration) + { + TestAsset testInstance = TestAssetsManager.CopyTestAsset("TraversalTestProjects", Guid.NewGuid().ToString()) + .WithSource(); + + CommandResult result = new DotnetTestCommand(Log, disableNewOutput: false) + .WithWorkingDirectory(testInstance.Path) + .Execute("dirs.proj", "-c", configuration); + + if (!SdkTestContext.IsLocalized()) + { + // The traversal project itself is not a test project. It should expand to its referenced + // test projects, both of which run (and report zero tests via the dummy adapter). + result.StdOut + .Should().Contain("Test run summary: Zero tests ran") + .And.Contain("total: 0") + .And.Contain("succeeded: 0") + .And.Contain("failed: 0") + .And.Contain("skipped: 0"); + } + + result.ExitCode.Should().Be(ExitCodes.ZeroTests); + } + + [DataRow(TestingConstants.Debug)] + [DataRow(TestingConstants.Release)] + [TestMethod] + public void RunNestedTraversalProjectWithDiamond_ShouldRunSharedProjectOnce(string configuration) + { + TestAsset testInstance = TestAssetsManager.CopyTestAsset("TraversalTestProjectsNested", Guid.NewGuid().ToString()) + .WithSource(); + + // Each test-host launch drops a uniquely-named marker file, giving a deterministic count of + // how many times each referenced project actually ran (robust to terminal progress re-rendering). + string markerDir = Path.Combine(testInstance.Path, "run-markers"); + + CommandResult result = new DotnetTestCommand(Log, disableNewOutput: false) + .WithWorkingDirectory(testInstance.Path) + .WithEnvironmentVariable("TRAVERSAL_MARKER_DIR", markerDir) + .Execute("dirs.proj", "-c", configuration); + + // SharedTestProject is referenced by both the top-level dirs.proj and the nested sub\dirs.proj + // (a diamond). Cross-recursion de-duplication must ensure it is only run once, while the leaf + // project reachable only through the nested traversal must also run (proving recursion works). + int sharedRuns = Directory.Exists(markerDir) ? Directory.GetFiles(markerDir, "SharedTestProject-*.marker").Length : 0; + int leafRuns = Directory.Exists(markerDir) ? Directory.GetFiles(markerDir, "LeafTestProject-*.marker").Length : 0; + + sharedRuns.Should().Be(1); + leafRuns.Should().Be(1); + + result.ExitCode.Should().Be(ExitCodes.ZeroTests); + } + [DataRow(TestingConstants.Debug)] [DataRow(TestingConstants.Release)] [TestMethod]