From e3b6a6b65abc4768f7bf374d9c8a6fa19429aaf3 Mon Sep 17 00:00:00 2001 From: Amaury Leveugle Date: Wed, 15 Jul 2026 12:23:21 +0200 Subject: [PATCH 1/3] Draft: support Microsoft.Build.Traversal projects in dotnet test (MTP) Unblocks traversal (dirs.proj) support for 'dotnet test' with Microsoft.Testing.Platform (issue #51316). Three coordinated changes mirror how solutions are handled - the traversal project is a container that forwards to its ProjectReference items: - CLI evaluation: special-case the 'IsTraversal' MSBuild property in SolutionAndProjectUtility.GetProjectProperties. When a project is a traversal project, expand it into its resolved ProjectReference items and evaluate each recursively (so nested traversal projects work). - CLI arg parsing: recognize any '*proj' extension (not just .csproj/.vbproj/.fsproj) as a positional project in MSBuildUtility.GetPositionalArguments, matching ValidateProjectOrSolutionPath. Without this, 'dotnet test dirs.proj' leaked 'dirs.proj' to the test host as an invalid argument (MTP exit code 5). - Targets: the _MTPBuild target now forwards to @(ProjectReference) when IsTraversal is true, so the referenced test projects actually build. Adds a TraversalTestProjects test asset and RunTraversalProject_ShouldRunReferencedTestProjects test. --- src/Cli/dotnet/Commands/Test/CliConstants.cs | 2 + .../Commands/Test/MTP/MSBuildUtility.cs | 8 ++-- .../Test/MTP/SolutionAndProjectUtility.cs | 43 ++++++++++++++++++- ...Microsoft.TestPlatform.ImportAfter.targets | 3 ++ .../OtherTestProject/OtherTestProject.csproj | 19 ++++++++ .../OtherTestProject/Program.cs | 38 ++++++++++++++++ .../TestProject/Program.cs | 38 ++++++++++++++++ .../TestProject/TestProject.csproj | 19 ++++++++ .../TraversalTestProjects/dirs.proj | 6 +++ .../TraversalTestProjects/global.json | 5 +++ .../Test/GivenDotnetTestBuildsAndRunsTests.cs | 27 ++++++++++++ 11 files changed, 204 insertions(+), 4 deletions(-) create mode 100644 test/TestAssets/TestProjects/TraversalTestProjects/OtherTestProject/OtherTestProject.csproj create mode 100644 test/TestAssets/TestProjects/TraversalTestProjects/OtherTestProject/Program.cs create mode 100644 test/TestAssets/TestProjects/TraversalTestProjects/TestProject/Program.cs create mode 100644 test/TestAssets/TestProjects/TraversalTestProjects/TestProject/TestProject.csproj create mode 100644 test/TestAssets/TestProjects/TraversalTestProjects/dirs.proj create mode 100644 test/TestAssets/TestProjects/TraversalTestProjects/global.json diff --git a/src/Cli/dotnet/Commands/Test/CliConstants.cs b/src/Cli/dotnet/Commands/Test/CliConstants.cs index 866d86dfe299..cb6ebccf0e94 100644 --- a/src/Cli/dotnet/Commands/Test/CliConstants.cs +++ b/src/Cli/dotnet/Commands/Test/CliConstants.cs @@ -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"; } diff --git a/src/Cli/dotnet/Commands/Test/MTP/MSBuildUtility.cs b/src/Cli/dotnet/Commands/Test/MTP/MSBuildUtility.cs index 1041731ca0f2..03fa5861305e 100644 --- a/src/Cli/dotnet/Commands/Test/MTP/MSBuildUtility.cs +++ b/src/Cli/dotnet/Commands/Test/MTP/MSBuildUtility.cs @@ -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; diff --git a/src/Cli/dotnet/Commands/Test/MTP/SolutionAndProjectUtility.cs b/src/Cli/dotnet/Commands/Test/MTP/SolutionAndProjectUtility.cs index 3dbe46c66b0f..540773063029 100644 --- a/src/Cli/dotnet/Commands/Test/MTP/SolutionAndProjectUtility.cs +++ b/src/Cli/dotnet/Commands/Test/MTP/SolutionAndProjectUtility.cs @@ -245,6 +245,20 @@ public static IEnumerable(); 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)) + { + foreach (var referencedProjectFullPath in GetTraversalReferencedProjects(projectInstance)) + { + projects.AddRange(GetProjectProperties(referencedProjectFullPath, projectCollection, evaluationContext, buildOptions, configuration, platform)); + } + + return projects; + } + var targetFramework = projectInstance.GetPropertyValue(ProjectProperties.TargetFramework); var targetFrameworks = projectInstance.GetPropertyValue(ProjectProperties.TargetFrameworks); @@ -312,7 +326,34 @@ 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; + + /// + /// Returns the full paths of 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. + /// + private static IEnumerable GetTraversalReferencedProjects(ProjectInstance projectInstance) + { + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + 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) && seen.Add(fullPath)) + { + yield return fullPath; + } + } + } + + /// /// 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..52a05241b154 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,8 @@ Copyright (c) .NET Foundation. All rights reserved. + + 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/dotnet.Tests/CommandTests/Test/GivenDotnetTestBuildsAndRunsTests.cs b/test/dotnet.Tests/CommandTests/Test/GivenDotnetTestBuildsAndRunsTests.cs index be5adca9ad7a..bf53df5cd202 100644 --- a/test/dotnet.Tests/CommandTests/Test/GivenDotnetTestBuildsAndRunsTests.cs +++ b/test/dotnet.Tests/CommandTests/Test/GivenDotnetTestBuildsAndRunsTests.cs @@ -411,6 +411,33 @@ 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] From 6b62e5988d07b6ce02f26a22184860c66afe7491 Mon Sep 17 00:00:00 2001 From: Amaury Leveugle Date: Wed, 15 Jul 2026 12:41:40 +0200 Subject: [PATCH 2/3] Add cross-graph de-dup and per-reference Configuration/Platform metadata for traversal - De-duplicate referenced projects across the entire traversal graph (not just within a single traversal file) by threading a visited-set through the recursion. This prevents a project referenced by multiple traversal projects (a diamond) from being tested twice and guards against reference cycles. - Honor per-reference Configuration/Platform metadata on ProjectReference items, falling back to the values inherited from the traversal project. - Add a nested/diamond test asset (TraversalTestProjectsNested) and RunNestedTraversalProjectWithDiamond_ShouldRunSharedProjectOnce. The test apps drop a unique marker file per launch so the test deterministically asserts the shared project runs exactly once and the leaf-only project (reachable solely via nested traversal) also runs once. --- .../Test/MTP/SolutionAndProjectUtility.cs | 46 +++++++++++++---- .../LeafTestProject/LeafTestProject.csproj | 19 +++++++ .../LeafTestProject/Program.cs | 49 +++++++++++++++++++ .../SharedTestProject/Program.cs | 49 +++++++++++++++++++ .../SharedTestProject.csproj | 19 +++++++ .../TraversalTestProjectsNested/dirs.proj | 8 +++ .../TraversalTestProjectsNested/global.json | 5 ++ .../TraversalTestProjectsNested/sub/dirs.proj | 6 +++ .../Test/GivenDotnetTestBuildsAndRunsTests.cs | 29 +++++++++++ 9 files changed, 220 insertions(+), 10 deletions(-) create mode 100644 test/TestAssets/TestProjects/TraversalTestProjectsNested/LeafTestProject/LeafTestProject.csproj create mode 100644 test/TestAssets/TestProjects/TraversalTestProjectsNested/LeafTestProject/Program.cs create mode 100644 test/TestAssets/TestProjects/TraversalTestProjectsNested/SharedTestProject/Program.cs create mode 100644 test/TestAssets/TestProjects/TraversalTestProjectsNested/SharedTestProject/SharedTestProject.csproj create mode 100644 test/TestAssets/TestProjects/TraversalTestProjectsNested/dirs.proj create mode 100644 test/TestAssets/TestProjects/TraversalTestProjectsNested/global.json create mode 100644 test/TestAssets/TestProjects/TraversalTestProjectsNested/sub/dirs.proj diff --git a/src/Cli/dotnet/Commands/Test/MTP/SolutionAndProjectUtility.cs b/src/Cli/dotnet/Commands/Test/MTP/SolutionAndProjectUtility.cs index 540773063029..a01e99bc8140 100644 --- a/src/Cli/dotnet/Commands/Test/MTP/SolutionAndProjectUtility.cs +++ b/src/Cli/dotnet/Commands/Test/MTP/SolutionAndProjectUtility.cs @@ -240,7 +240,8 @@ public static IEnumerable? visitedTraversalProjects = null) { var projects = new List(); ProjectInstance projectInstance = EvaluateProject(projectCollection, evaluationContext, projectFilePath, tfm: null, configuration, platform); @@ -251,9 +252,21 @@ public static IEnumerable(StringComparer.OrdinalIgnoreCase); + visitedTraversalProjects.Add(Path.GetFullPath(projectFilePath)); + + foreach (var reference in GetTraversalReferencedProjects(projectInstance, configuration, platform)) { - projects.AddRange(GetProjectProperties(referencedProjectFullPath, projectCollection, evaluationContext, buildOptions, configuration, platform)); + if (!visitedTraversalProjects.Add(reference.FullPath)) + { + // Already handled via another traversal path (diamond) or a cycle. + continue; + } + + projects.AddRange(GetProjectProperties(reference.FullPath, projectCollection, evaluationContext, buildOptions, reference.Configuration, reference.Platform, visitedTraversalProjects)); } return projects; @@ -335,21 +348,34 @@ private static bool IsTraversalProject(ProjectInstance projectInstance) => bool.TryParse(projectInstance.GetPropertyValue(ProjectProperties.IsTraversal), out bool isTraversal) && isTraversal; /// - /// Returns the full paths of 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. + /// 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 GetTraversalReferencedProjects(ProjectInstance projectInstance) + private static IEnumerable<(string FullPath, string? Configuration, string? Platform)> GetTraversalReferencedProjects( + ProjectInstance projectInstance, + string? inheritedConfiguration, + string? inheritedPlatform) { - var seen = new HashSet(StringComparer.OrdinalIgnoreCase); 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) && seen.Add(fullPath)) + if (string.IsNullOrEmpty(fullPath)) { - yield return 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); } } 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 bf53df5cd202..680cf6303d47 100644 --- a/test/dotnet.Tests/CommandTests/Test/GivenDotnetTestBuildsAndRunsTests.cs +++ b/test/dotnet.Tests/CommandTests/Test/GivenDotnetTestBuildsAndRunsTests.cs @@ -438,6 +438,35 @@ public void RunTraversalProject_ShouldRunReferencedTestProjects(string configura 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] From 0cdfb50177559c0998a14be6b667cca0eb5f30ac Mon Sep 17 00:00:00 2001 From: Amaury Leveugle Date: Wed, 15 Jul 2026 16:43:10 +0200 Subject: [PATCH 3/3] Address review: safe FullPath base dir, config/platform-aware dedup, targets metadata forwarding Addresses three Copilot review comments on #55297: - Resolve ProjectReference 'FullPath' relative to the traversal project directory via Path.GetFullPath(fullPath, projectDirectory) instead of the process working directory. - Include Configuration/Platform in the traversal de-duplication key so the same project referenced with different Configuration/Platform is tested for each distinct combination, while true diamonds (same combination) are still de-duplicated. Introduces GetTraversalVisitKey and keys the traversal project's own visited entry the same way. - _MTPBuild now forwards per-reference Configuration/Platform metadata as global properties, consistent with the evaluation logic. Property assignments are precomputed in an ItemGroup so empty metadata never emits 'Configuration=' (which would clear the inherited global property); empty segments in Properties are ignored by MSBuild. --- .../Test/MTP/SolutionAndProjectUtility.cs | 30 ++++++++++++++----- ...Microsoft.TestPlatform.ImportAfter.targets | 19 ++++++++++-- 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/src/Cli/dotnet/Commands/Test/MTP/SolutionAndProjectUtility.cs b/src/Cli/dotnet/Commands/Test/MTP/SolutionAndProjectUtility.cs index a01e99bc8140..9c6357e1aa9a 100644 --- a/src/Cli/dotnet/Commands/Test/MTP/SolutionAndProjectUtility.cs +++ b/src/Cli/dotnet/Commands/Test/MTP/SolutionAndProjectUtility.cs @@ -252,17 +252,20 @@ public static IEnumerable(StringComparer.OrdinalIgnoreCase); - visitedTraversalProjects.Add(Path.GetFullPath(projectFilePath)); + visitedTraversalProjects.Add(GetTraversalVisitKey(Path.GetFullPath(projectFilePath), configuration, platform)); foreach (var reference in GetTraversalReferencedProjects(projectInstance, configuration, platform)) { - if (!visitedTraversalProjects.Add(reference.FullPath)) + if (!visitedTraversalProjects.Add(GetTraversalVisitKey(reference.FullPath, reference.Configuration, reference.Platform))) { - // Already handled via another traversal path (diamond) or a cycle. + // Already handled via another traversal path (diamond) or a cycle, with the same + // configuration/platform combination. continue; } @@ -347,6 +350,13 @@ public static IEnumerable 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 @@ -360,6 +370,12 @@ private static bool IsTraversalProject(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. @@ -373,7 +389,7 @@ private static bool IsTraversalProject(ProjectInstance projectInstance) var platformMetadata = projectReference.GetMetadataValue(ProjectProperties.Platform); yield return ( - Path.GetFullPath(fullPath), + Path.GetFullPath(fullPath, projectDirectory), string.IsNullOrEmpty(configurationMetadata) ? inheritedConfiguration : configurationMetadata, string.IsNullOrEmpty(platformMetadata) ? inheritedPlatform : platformMetadata); } 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 52a05241b154..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,8 +19,23 @@ Copyright (c) .NET Foundation. All rights reserved. + - + 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. --> + + <_MTPTraversalReference Include="@(ProjectReference)"> + <_ConfigurationProperty Condition="'%(ProjectReference.Configuration)' != ''">Configuration=%(ProjectReference.Configuration) + <_PlatformProperty Condition="'%(ProjectReference.Platform)' != ''">Platform=%(ProjectReference.Platform) + + +