diff --git a/src/Cli/Microsoft.DotNet.Cli.Definitions/CommandDefinitionStrings.resx b/src/Cli/Microsoft.DotNet.Cli.Definitions/CommandDefinitionStrings.resx
index dd3f37f5eaaf..25f532dc1e6d 100644
--- a/src/Cli/Microsoft.DotNet.Cli.Definitions/CommandDefinitionStrings.resx
+++ b/src/Cli/Microsoft.DotNet.Cli.Definitions/CommandDefinitionStrings.resx
@@ -555,6 +555,14 @@ This is equivalent to deleting project.assets.json.
The directory where the test results will be placed.
The specified directory will be created if it does not exist.
+
+ Specifies how test results are organized within the results directory.
+'flat' (the default) places the results of every test project directly in the results directory.
+'per-module' gives every test project its own '<project>/<target-framework>_<runtime>' subdirectory, so reports with the same file name cannot overwrite each other.
+
+
+ LAYOUT
+
Specifies a testconfig.json file.
diff --git a/src/Cli/Microsoft.DotNet.Cli.Definitions/Commands/Test/TestCommandDefinition.MicrosoftTestingPlatform.cs b/src/Cli/Microsoft.DotNet.Cli.Definitions/Commands/Test/TestCommandDefinition.MicrosoftTestingPlatform.cs
index 03f192adb496..5d454e7cdecc 100644
--- a/src/Cli/Microsoft.DotNet.Cli.Definitions/Commands/Test/TestCommandDefinition.MicrosoftTestingPlatform.cs
+++ b/src/Cli/Microsoft.DotNet.Cli.Definitions/Commands/Test/TestCommandDefinition.MicrosoftTestingPlatform.cs
@@ -46,6 +46,13 @@ public sealed class MicrosoftTestingPlatform : TestCommandDefinition, ICustomHel
Arity = ArgumentArity.ExactlyOne
};
+ public readonly Option ResultsDirectoryLayoutOption = new Option("--results-directory-layout")
+ {
+ Description = CommandDefinitionStrings.CmdResultsDirectoryLayoutDescription,
+ HelpName = CommandDefinitionStrings.CmdResultsDirectoryLayoutName,
+ Arity = ArgumentArity.ExactlyOne
+ }.AcceptOnlyFromAmong("flat", "per-module");
+
public const string ConfigFileOptionName = "--config-file";
public readonly Option ConfigFileOption = new(ConfigFileOptionName)
@@ -140,6 +147,7 @@ public MicrosoftTestingPlatform()
Options.Add(TestModulesFilterOption);
Options.Add(TestModulesRootDirectoryOption);
Options.Add(ResultsDirectoryOption);
+ Options.Add(ResultsDirectoryLayoutOption);
Options.Add(ConfigFileOption);
Options.Add(DiagnosticOutputDirectoryOption);
Options.Add(MaxParallelTestModulesOption);
diff --git a/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.cs.xlf b/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.cs.xlf
index 8b0cbac07392..4b30cc0c02c5 100644
--- a/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.cs.xlf
+++ b/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.cs.xlf
@@ -561,6 +561,20 @@ The specified directory will be created if it does not exist.
Pokud zadaný adresář neexistuje, bude vytvořen.
+
+ Specifies how test results are organized within the results directory.
+'flat' (the default) places the results of every test project directly in the results directory.
+'per-module' gives every test project its own '<project>/<target-framework>_<runtime>' subdirectory, so reports with the same file name cannot overwrite each other.
+ Specifies how test results are organized within the results directory.
+'flat' (the default) places the results of every test project directly in the results directory.
+'per-module' gives every test project its own '<project>/<target-framework>_<runtime>' subdirectory, so reports with the same file name cannot overwrite each other.
+
+
+
+ LAYOUT
+ LAYOUT
+
+ ROOT_PATHROOT_PATH
diff --git a/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.de.xlf b/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.de.xlf
index 93fc1de8a0db..6757f3e30e1c 100644
--- a/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.de.xlf
+++ b/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.de.xlf
@@ -561,6 +561,20 @@ The specified directory will be created if it does not exist.
Das angegebene Verzeichnis wird erstellt, wenn es nicht vorhanden ist.
+
+ Specifies how test results are organized within the results directory.
+'flat' (the default) places the results of every test project directly in the results directory.
+'per-module' gives every test project its own '<project>/<target-framework>_<runtime>' subdirectory, so reports with the same file name cannot overwrite each other.
+ Specifies how test results are organized within the results directory.
+'flat' (the default) places the results of every test project directly in the results directory.
+'per-module' gives every test project its own '<project>/<target-framework>_<runtime>' subdirectory, so reports with the same file name cannot overwrite each other.
+
+
+
+ LAYOUT
+ LAYOUT
+
+ ROOT_PATHROOT_PATH
diff --git a/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.es.xlf b/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.es.xlf
index 836c6693ae66..5b6be9cbfc24 100644
--- a/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.es.xlf
+++ b/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.es.xlf
@@ -561,6 +561,20 @@ The specified directory will be created if it does not exist.
Si no existe, se creará el directorio especificado.
+
+ Specifies how test results are organized within the results directory.
+'flat' (the default) places the results of every test project directly in the results directory.
+'per-module' gives every test project its own '<project>/<target-framework>_<runtime>' subdirectory, so reports with the same file name cannot overwrite each other.
+ Specifies how test results are organized within the results directory.
+'flat' (the default) places the results of every test project directly in the results directory.
+'per-module' gives every test project its own '<project>/<target-framework>_<runtime>' subdirectory, so reports with the same file name cannot overwrite each other.
+
+
+
+ LAYOUT
+ LAYOUT
+
+ ROOT_PATHROOT_PATH
diff --git a/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.fr.xlf b/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.fr.xlf
index c4954bc10503..a1b0c3ae1013 100644
--- a/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.fr.xlf
+++ b/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.fr.xlf
@@ -561,6 +561,20 @@ The specified directory will be created if it does not exist.
Le répertoire spécifié est créé, s'il n'existe pas déjà.
+
+ Specifies how test results are organized within the results directory.
+'flat' (the default) places the results of every test project directly in the results directory.
+'per-module' gives every test project its own '<project>/<target-framework>_<runtime>' subdirectory, so reports with the same file name cannot overwrite each other.
+ Specifies how test results are organized within the results directory.
+'flat' (the default) places the results of every test project directly in the results directory.
+'per-module' gives every test project its own '<project>/<target-framework>_<runtime>' subdirectory, so reports with the same file name cannot overwrite each other.
+
+
+
+ LAYOUT
+ LAYOUT
+
+ ROOT_PATHROOT_PATH
diff --git a/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.it.xlf b/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.it.xlf
index 41dc531e84eb..bd104a63e9e7 100644
--- a/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.it.xlf
+++ b/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.it.xlf
@@ -561,6 +561,20 @@ The specified directory will be created if it does not exist.
Se non esiste, la directory specificata verrà creata.
+
+ Specifies how test results are organized within the results directory.
+'flat' (the default) places the results of every test project directly in the results directory.
+'per-module' gives every test project its own '<project>/<target-framework>_<runtime>' subdirectory, so reports with the same file name cannot overwrite each other.
+ Specifies how test results are organized within the results directory.
+'flat' (the default) places the results of every test project directly in the results directory.
+'per-module' gives every test project its own '<project>/<target-framework>_<runtime>' subdirectory, so reports with the same file name cannot overwrite each other.
+
+
+
+ LAYOUT
+ LAYOUT
+
+ ROOT_PATHROOT_PATH
diff --git a/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.ja.xlf b/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.ja.xlf
index 23cdd56b6552..3aa8a5036af9 100644
--- a/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.ja.xlf
+++ b/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.ja.xlf
@@ -561,6 +561,20 @@ The specified directory will be created if it does not exist.
指定したディレクトリが存在しない場合は、作成されます。
+
+ Specifies how test results are organized within the results directory.
+'flat' (the default) places the results of every test project directly in the results directory.
+'per-module' gives every test project its own '<project>/<target-framework>_<runtime>' subdirectory, so reports with the same file name cannot overwrite each other.
+ Specifies how test results are organized within the results directory.
+'flat' (the default) places the results of every test project directly in the results directory.
+'per-module' gives every test project its own '<project>/<target-framework>_<runtime>' subdirectory, so reports with the same file name cannot overwrite each other.
+
+
+
+ LAYOUT
+ LAYOUT
+
+ ROOT_PATHROOT_PATH
diff --git a/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.ko.xlf b/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.ko.xlf
index cbc79b2c7a84..296465095e07 100644
--- a/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.ko.xlf
+++ b/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.ko.xlf
@@ -561,6 +561,20 @@ The specified directory will be created if it does not exist.
지정한 디렉터리가 존재하지 않는 경우 생성됩니다.
+
+ Specifies how test results are organized within the results directory.
+'flat' (the default) places the results of every test project directly in the results directory.
+'per-module' gives every test project its own '<project>/<target-framework>_<runtime>' subdirectory, so reports with the same file name cannot overwrite each other.
+ Specifies how test results are organized within the results directory.
+'flat' (the default) places the results of every test project directly in the results directory.
+'per-module' gives every test project its own '<project>/<target-framework>_<runtime>' subdirectory, so reports with the same file name cannot overwrite each other.
+
+
+
+ LAYOUT
+ LAYOUT
+
+ ROOT_PATHROOT_PATH
diff --git a/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.pl.xlf b/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.pl.xlf
index 0cbee8014d10..0e1aa307a123 100644
--- a/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.pl.xlf
+++ b/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.pl.xlf
@@ -561,6 +561,20 @@ The specified directory will be created if it does not exist.
Jeśli określony katalog nie istnieje, zostanie utworzony.
+
+ Specifies how test results are organized within the results directory.
+'flat' (the default) places the results of every test project directly in the results directory.
+'per-module' gives every test project its own '<project>/<target-framework>_<runtime>' subdirectory, so reports with the same file name cannot overwrite each other.
+ Specifies how test results are organized within the results directory.
+'flat' (the default) places the results of every test project directly in the results directory.
+'per-module' gives every test project its own '<project>/<target-framework>_<runtime>' subdirectory, so reports with the same file name cannot overwrite each other.
+
+
+
+ LAYOUT
+ LAYOUT
+
+ ROOT_PATHROOT_PATH
diff --git a/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.pt-BR.xlf b/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.pt-BR.xlf
index 83cfdc3001f2..5e8a26f0e1a5 100644
--- a/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.pt-BR.xlf
+++ b/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.pt-BR.xlf
@@ -561,6 +561,20 @@ The specified directory will be created if it does not exist.
O diretório especificado será criado se ele ainda não existir.
+
+ Specifies how test results are organized within the results directory.
+'flat' (the default) places the results of every test project directly in the results directory.
+'per-module' gives every test project its own '<project>/<target-framework>_<runtime>' subdirectory, so reports with the same file name cannot overwrite each other.
+ Specifies how test results are organized within the results directory.
+'flat' (the default) places the results of every test project directly in the results directory.
+'per-module' gives every test project its own '<project>/<target-framework>_<runtime>' subdirectory, so reports with the same file name cannot overwrite each other.
+
+
+
+ LAYOUT
+ LAYOUT
+
+ ROOT_PATHROOT_PATH
diff --git a/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.ru.xlf b/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.ru.xlf
index 2a95d0254326..4549997eaee7 100644
--- a/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.ru.xlf
+++ b/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.ru.xlf
@@ -561,6 +561,20 @@ The specified directory will be created if it does not exist.
Если указанного каталога не существует, он будет создан.
+
+ Specifies how test results are organized within the results directory.
+'flat' (the default) places the results of every test project directly in the results directory.
+'per-module' gives every test project its own '<project>/<target-framework>_<runtime>' subdirectory, so reports with the same file name cannot overwrite each other.
+ Specifies how test results are organized within the results directory.
+'flat' (the default) places the results of every test project directly in the results directory.
+'per-module' gives every test project its own '<project>/<target-framework>_<runtime>' subdirectory, so reports with the same file name cannot overwrite each other.
+
+
+
+ LAYOUT
+ LAYOUT
+
+ ROOT_PATHROOT_PATH
diff --git a/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.tr.xlf b/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.tr.xlf
index 985efbe08ced..ce1504157555 100644
--- a/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.tr.xlf
+++ b/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.tr.xlf
@@ -561,6 +561,20 @@ The specified directory will be created if it does not exist.
Belirtilen dizin yoksa oluşturulur.
+
+ Specifies how test results are organized within the results directory.
+'flat' (the default) places the results of every test project directly in the results directory.
+'per-module' gives every test project its own '<project>/<target-framework>_<runtime>' subdirectory, so reports with the same file name cannot overwrite each other.
+ Specifies how test results are organized within the results directory.
+'flat' (the default) places the results of every test project directly in the results directory.
+'per-module' gives every test project its own '<project>/<target-framework>_<runtime>' subdirectory, so reports with the same file name cannot overwrite each other.
+
+
+
+ LAYOUT
+ LAYOUT
+
+ ROOT_PATHROOT_PATH
diff --git a/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.zh-Hans.xlf b/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.zh-Hans.xlf
index a16c9b2c1d4f..23d558822ef9 100644
--- a/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.zh-Hans.xlf
+++ b/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.zh-Hans.xlf
@@ -561,6 +561,20 @@ The specified directory will be created if it does not exist.
若不存在,将创建指定目录。
+
+ Specifies how test results are organized within the results directory.
+'flat' (the default) places the results of every test project directly in the results directory.
+'per-module' gives every test project its own '<project>/<target-framework>_<runtime>' subdirectory, so reports with the same file name cannot overwrite each other.
+ Specifies how test results are organized within the results directory.
+'flat' (the default) places the results of every test project directly in the results directory.
+'per-module' gives every test project its own '<project>/<target-framework>_<runtime>' subdirectory, so reports with the same file name cannot overwrite each other.
+
+
+
+ LAYOUT
+ LAYOUT
+
+ ROOT_PATHROOT_PATH
diff --git a/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.zh-Hant.xlf b/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.zh-Hant.xlf
index 4a4e57cb822b..d7713772d0b0 100644
--- a/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.zh-Hant.xlf
+++ b/src/Cli/Microsoft.DotNet.Cli.Definitions/xlf/CommandDefinitionStrings.zh-Hant.xlf
@@ -561,6 +561,20 @@ The specified directory will be created if it does not exist.
若指定的目錄不存在,則會建立該目錄。
+
+ Specifies how test results are organized within the results directory.
+'flat' (the default) places the results of every test project directly in the results directory.
+'per-module' gives every test project its own '<project>/<target-framework>_<runtime>' subdirectory, so reports with the same file name cannot overwrite each other.
+ Specifies how test results are organized within the results directory.
+'flat' (the default) places the results of every test project directly in the results directory.
+'per-module' gives every test project its own '<project>/<target-framework>_<runtime>' subdirectory, so reports with the same file name cannot overwrite each other.
+
+
+
+ LAYOUT
+ LAYOUT
+
+ ROOT_PATHROOT_PATH
diff --git a/src/Cli/dotnet/Commands/Test/MTP/MSBuildHandler.cs b/src/Cli/dotnet/Commands/Test/MTP/MSBuildHandler.cs
index cb36ef2fdac8..a0aabc0fabe5 100644
--- a/src/Cli/dotnet/Commands/Test/MTP/MSBuildHandler.cs
+++ b/src/Cli/dotnet/Commands/Test/MTP/MSBuildHandler.cs
@@ -74,6 +74,9 @@ public void EnqueueTestApplications(TestApplicationActionQueue queue)
}
}
+ public IEnumerable EnumerateTestModules()
+ => _testApplications.SelectMany(static moduleGroup => moduleGroup);
+
private static void LogProjectProperties(IEnumerable moduleGroups)
{
if (!Logger.TraceEnabled)
diff --git a/src/Cli/dotnet/Commands/Test/MTP/MSBuildUtility.cs b/src/Cli/dotnet/Commands/Test/MTP/MSBuildUtility.cs
index af0b4c348792..5d60df60b301 100644
--- a/src/Cli/dotnet/Commands/Test/MTP/MSBuildUtility.cs
+++ b/src/Cli/dotnet/Commands/Test/MTP/MSBuildUtility.cs
@@ -140,6 +140,9 @@ public static BuildOptions GetBuildOptions(ParseResult parseResult)
parseResult.GetValue(definition.SolutionOption),
positionalTestModules ?? parseResult.GetValue(definition.TestModulesFilterOption),
resultsDirectory,
+ parseResult.GetValue(definition.ResultsDirectoryLayoutOption) == "per-module"
+ ? ResultsDirectoryLayout.PerModule
+ : ResultsDirectoryLayout.Flat,
configFile,
diagnosticOutputDirectory);
diff --git a/src/Cli/dotnet/Commands/Test/MTP/MicrosoftTestingPlatformTestCommand.cs b/src/Cli/dotnet/Commands/Test/MTP/MicrosoftTestingPlatformTestCommand.cs
index e92590025594..e303fec17767 100644
--- a/src/Cli/dotnet/Commands/Test/MTP/MicrosoftTestingPlatformTestCommand.cs
+++ b/src/Cli/dotnet/Commands/Test/MTP/MicrosoftTestingPlatformTestCommand.cs
@@ -47,12 +47,24 @@ private int RunInternal(ParseResult parseResult, bool isHelp)
{
InitializeOutput(degreeOfParallelism, parseResult, testOptions);
- actionQueue = new TestApplicationActionQueue(degreeOfParallelism, buildOptions, testOptions, _output, OnHelpRequested);
- var testModulesFilterHandler = new TestModulesFilterHandler(actionQueue, _output);
+ var testModulesFilterHandler = new TestModulesFilterHandler(_output);
if (!testModulesFilterHandler.RunWithTestModulesFilter(parseResult, buildOptions.PathOptions.TestModules))
{
return ExitCode.GenericFailure;
}
+
+ TestResultsDirectoryResolver resultsDirectoryResolver = TestResultsDirectoryResolver.Create(
+ buildOptions.PathOptions,
+ testModulesFilterHandler.EnumerateTestModules(),
+ Directory.GetCurrentDirectory());
+ actionQueue = new TestApplicationActionQueue(
+ degreeOfParallelism,
+ buildOptions,
+ testOptions,
+ resultsDirectoryResolver,
+ _output,
+ OnHelpRequested);
+ testModulesFilterHandler.EnqueueTestApplications(actionQueue);
}
else
{
@@ -68,7 +80,17 @@ private int RunInternal(ParseResult parseResult, bool isHelp)
// The constructor will do Task.Run calls matching the degree of parallelism, and if we did that before the build, that can
// be slowing us down unnecessarily.
// Alternatively, if we can enqueue right after every project evaluation without waiting all evaluations to be done, we can enqueue early.
- actionQueue = new TestApplicationActionQueue(degreeOfParallelism, buildOptions, testOptions, _output, OnHelpRequested);
+ TestResultsDirectoryResolver resultsDirectoryResolver = TestResultsDirectoryResolver.Create(
+ buildOptions.PathOptions,
+ msBuildHandler.EnumerateTestModules(),
+ Directory.GetCurrentDirectory());
+ actionQueue = new TestApplicationActionQueue(
+ degreeOfParallelism,
+ buildOptions,
+ testOptions,
+ resultsDirectoryResolver,
+ _output,
+ OnHelpRequested);
msBuildHandler.EnqueueTestApplications(actionQueue);
}
diff --git a/src/Cli/dotnet/Commands/Test/MTP/Options.cs b/src/Cli/dotnet/Commands/Test/MTP/Options.cs
index d311b62d9721..45312949e455 100644
--- a/src/Cli/dotnet/Commands/Test/MTP/Options.cs
+++ b/src/Cli/dotnet/Commands/Test/MTP/Options.cs
@@ -5,7 +5,20 @@ namespace Microsoft.DotNet.Cli.Commands.Test;
internal record TestOptions(bool IsHelp, bool IsDiscovery, IReadOnlyDictionary EnvironmentVariables);
-internal record PathOptions(string? ProjectOrSolutionPath, string? SolutionPath, string? TestModules, string? ResultsDirectoryPath, string? ConfigFilePath, string? DiagnosticOutputDirectoryPath);
+internal enum ResultsDirectoryLayout
+{
+ Flat,
+ PerModule,
+}
+
+internal record PathOptions(
+ string? ProjectOrSolutionPath,
+ string? SolutionPath,
+ string? TestModules,
+ string? ResultsDirectoryPath,
+ ResultsDirectoryLayout ResultsDirectoryLayout,
+ string? ConfigFilePath,
+ string? DiagnosticOutputDirectoryPath);
internal record BuildOptions(
PathOptions PathOptions,
diff --git a/src/Cli/dotnet/Commands/Test/MTP/TestApplication.cs b/src/Cli/dotnet/Commands/Test/MTP/TestApplication.cs
index 645e86d57782..48be70a0d91a 100644
--- a/src/Cli/dotnet/Commands/Test/MTP/TestApplication.cs
+++ b/src/Cli/dotnet/Commands/Test/MTP/TestApplication.cs
@@ -20,11 +20,13 @@ internal sealed class TestApplication(
TestModule module,
BuildOptions buildOptions,
TestOptions testOptions,
+ TestResultsDirectoryResolver resultsDirectoryResolver,
TerminalTestReporter output,
Action onHelpRequested) : IDisposable
{
private readonly Lock _requestLock = new();
private readonly BuildOptions _buildOptions = buildOptions;
+ private readonly TestResultsDirectoryResolver _resultsDirectoryResolver = resultsDirectoryResolver;
private readonly Action _onHelpRequested = onHelpRequested;
private readonly TestApplicationHandler _handler = new(output, module, testOptions);
@@ -189,7 +191,7 @@ private string GetArguments()
builder.Append($" {TestCommandDefinition.MicrosoftTestingPlatform.ListTestsOptionName}");
}
- if (_buildOptions.PathOptions.ResultsDirectoryPath is { } resultsDirectoryPath)
+ if (_resultsDirectoryResolver.Resolve(Module) is { } resultsDirectoryPath)
{
builder.Append($" {TestCommandDefinition.MicrosoftTestingPlatform.ResultsDirectoryOptionName} {ArgumentEscaper.EscapeSingleArg(resultsDirectoryPath)}");
}
diff --git a/src/Cli/dotnet/Commands/Test/MTP/TestApplicationActionQueue.cs b/src/Cli/dotnet/Commands/Test/MTP/TestApplicationActionQueue.cs
index 4496703ace28..18c3629bd207 100644
--- a/src/Cli/dotnet/Commands/Test/MTP/TestApplicationActionQueue.cs
+++ b/src/Cli/dotnet/Commands/Test/MTP/TestApplicationActionQueue.cs
@@ -17,14 +17,20 @@ internal class TestApplicationActionQueue
private static readonly Lock _lock = new();
- public TestApplicationActionQueue(int degreeOfParallelism, BuildOptions buildOptions, TestOptions testOptions, TerminalTestReporter output, Action onHelpRequested)
+ public TestApplicationActionQueue(
+ int degreeOfParallelism,
+ BuildOptions buildOptions,
+ TestOptions testOptions,
+ TestResultsDirectoryResolver resultsDirectoryResolver,
+ TerminalTestReporter output,
+ Action onHelpRequested)
{
_channel = Channel.CreateUnbounded(new UnboundedChannelOptions { SingleReader = false, SingleWriter = false });
_readers = new Task[degreeOfParallelism];
for (int i = 0; i < degreeOfParallelism; i++)
{
- _readers[i] = Task.Run(async () => await Read(buildOptions, testOptions, output, onHelpRequested));
+ _readers[i] = Task.Run(async () => await Read(buildOptions, testOptions, resultsDirectoryResolver, output, onHelpRequested));
}
}
@@ -51,14 +57,19 @@ public void EnqueueCompleted()
_channel.Writer.Complete();
}
- private async Task Read(BuildOptions buildOptions, TestOptions testOptions, TerminalTestReporter output, Action onHelpRequested)
+ private async Task Read(
+ BuildOptions buildOptions,
+ TestOptions testOptions,
+ TestResultsDirectoryResolver resultsDirectoryResolver,
+ TerminalTestReporter output,
+ Action onHelpRequested)
{
await foreach (var nonParallelizedGroup in _channel.Reader.ReadAllAsync())
{
foreach (var module in nonParallelizedGroup)
{
int result = ExitCode.GenericFailure;
- var testApp = new TestApplication(module, buildOptions, testOptions, output, onHelpRequested);
+ var testApp = new TestApplication(module, buildOptions, testOptions, resultsDirectoryResolver, output, onHelpRequested);
try
{
using (testApp)
diff --git a/src/Cli/dotnet/Commands/Test/MTP/TestModulesFilterHandler.cs b/src/Cli/dotnet/Commands/Test/MTP/TestModulesFilterHandler.cs
index f55b8d4abc2d..e94b0cddece9 100644
--- a/src/Cli/dotnet/Commands/Test/MTP/TestModulesFilterHandler.cs
+++ b/src/Cli/dotnet/Commands/Test/MTP/TestModulesFilterHandler.cs
@@ -10,10 +10,10 @@
namespace Microsoft.DotNet.Cli.Commands.Test;
-internal sealed class TestModulesFilterHandler(TestApplicationActionQueue actionQueue, TerminalTestReporter output)
+internal sealed class TestModulesFilterHandler(TerminalTestReporter output)
{
- private readonly TestApplicationActionQueue _actionQueue = actionQueue;
private readonly TerminalTestReporter _output = output;
+ private List _testApplications = [];
public bool RunWithTestModulesFilter(ParseResult parseResult, string testModules)
{
@@ -46,7 +46,25 @@ public bool RunWithTestModulesFilter(ParseResult parseResult, string testModules
return false;
}
+ _testApplications = BuildTestApplications(testModulePaths);
+ return true;
+ }
+
+ public IEnumerable EnumerateTestModules()
+ => _testApplications.SelectMany(static moduleGroup => moduleGroup);
+
+ public void EnqueueTestApplications(TestApplicationActionQueue actionQueue)
+ {
+ foreach (ParallelizableTestModuleGroupWithSequentialInnerModules testApplication in _testApplications)
+ {
+ actionQueue.Enqueue(testApplication);
+ }
+ }
+
+ private static List BuildTestApplications(IEnumerable testModulePaths)
+ {
var muxerPath = new Muxer().MuxerPath;
+ List testApplications = [];
foreach (string testModule in testModulePaths)
{
// We want to produce the right RunCommand and RunArguments for TestApplication implementation to consume directly.
@@ -56,12 +74,11 @@ public bool RunWithTestModulesFilter(ParseResult parseResult, string testModules
? new RunProperties(muxerPath, $@"exec ""{testModule}""", null)
: new RunProperties(testModule, null, null);
- var testApp = new ParallelizableTestModuleGroupWithSequentialInnerModules(new TestModule(runProperties, null, null, true, null, testModule, DotnetRootArchVariableName: null));
- // Write the test application to the channel
- _actionQueue.Enqueue(testApp);
+ testApplications.Add(new ParallelizableTestModuleGroupWithSequentialInnerModules(
+ new TestModule(runProperties, null, null, true, null, testModule, DotnetRootArchVariableName: null)));
}
- return true;
+ return testApplications;
}
private static IEnumerable GetMatchedModulePaths(string testModules, string rootDirectory)
diff --git a/src/Cli/dotnet/Commands/Test/MTP/TestResultsDirectoryResolver.cs b/src/Cli/dotnet/Commands/Test/MTP/TestResultsDirectoryResolver.cs
new file mode 100644
index 000000000000..95eb81802465
--- /dev/null
+++ b/src/Cli/dotnet/Commands/Test/MTP/TestResultsDirectoryResolver.cs
@@ -0,0 +1,300 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Diagnostics;
+using System.Runtime.InteropServices;
+using System.Security.Cryptography;
+using System.Text;
+
+namespace Microsoft.DotNet.Cli.Commands.Test;
+
+///
+/// Computes the results directory handed to each test application.
+/// The per-module layout mirrors the SDK artifacts output layout
+/// (https://learn.microsoft.com/dotnet/core/sdk/artifacts-output): a project folder containing a
+/// pivot folder, where pivot elements are joined by an underscore.
+///
+/// Project names are not guaranteed to be unique within a run, so the whole module set is inspected
+/// up front. Only when two distinct projects would land in the same project folder is a short
+/// identity hash appended to disambiguate them, keeping the common case clean.
+///
+///
+internal sealed class TestResultsDirectoryResolver
+{
+ private const string DefaultResultsDirectoryName = "TestResults";
+ private const string UnknownComponent = "unknown";
+ private const int MaxPathComponentLength = 255;
+
+ private static readonly HashSet s_invalidPathComponentCharacters =
+ [.. Path.GetInvalidFileNameChars(), Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar];
+
+ private readonly PathOptions _pathOptions;
+ private readonly string _workingDirectory;
+ private readonly string _identityRoot;
+ private readonly HashSet _ambiguousProjectNames;
+
+ private TestResultsDirectoryResolver(PathOptions pathOptions, string workingDirectory, string identityRoot, HashSet ambiguousProjectNames)
+ {
+ _pathOptions = pathOptions;
+ _workingDirectory = workingDirectory;
+ _identityRoot = identityRoot;
+ _ambiguousProjectNames = ambiguousProjectNames;
+ }
+
+ public static TestResultsDirectoryResolver Create(PathOptions pathOptions, IEnumerable modules, string workingDirectory)
+ {
+ if (pathOptions.ResultsDirectoryLayout == ResultsDirectoryLayout.Flat)
+ {
+ return new TestResultsDirectoryResolver(pathOptions, workingDirectory, workingDirectory, []);
+ }
+ // Anchor identities to the directory shared by every module rather than the current
+ // directory, so the same solution produces the same folder names no matter where
+ // 'dotnet test' was invoked from.
+ List materializedModules = [.. modules];
+ string identityRoot = GetCommonRootDirectory(materializedModules, workingDirectory);
+
+ Dictionary> identitiesByProjectName = new(StringComparer.OrdinalIgnoreCase);
+ foreach (TestModule module in materializedModules)
+ {
+ string projectName = GetProjectName(module);
+ if (!identitiesByProjectName.TryGetValue(projectName, out HashSet? identities))
+ {
+ identities = new HashSet(StringComparer.Ordinal);
+ identitiesByProjectName.Add(projectName, identities);
+ }
+
+ identities.Add(GetProjectIdentity(module, identityRoot));
+ }
+
+ HashSet ambiguousProjectNames = new(StringComparer.OrdinalIgnoreCase);
+ foreach ((string projectName, HashSet identities) in identitiesByProjectName)
+ {
+ if (identities.Count > 1)
+ {
+ ambiguousProjectNames.Add(projectName);
+ }
+ }
+
+ return new TestResultsDirectoryResolver(pathOptions, workingDirectory, identityRoot, ambiguousProjectNames);
+ }
+
+ public string? Resolve(TestModule module)
+ {
+ if (_pathOptions.ResultsDirectoryLayout == ResultsDirectoryLayout.Flat)
+ {
+ return _pathOptions.ResultsDirectoryPath;
+ }
+
+ string resultsDirectory = _pathOptions.ResultsDirectoryPath
+ ?? Path.Combine(_workingDirectory, DefaultResultsDirectoryName);
+
+ string resolved = Path.GetFullPath(
+ Path.Combine(resultsDirectory, GetProjectDirectoryName(module), GetPivotDirectoryName(module)));
+
+ // Sanitization strips separators and dot-only components, so a module can never steer its
+ // results out of the requested root. Asserted rather than thrown because it is unreachable
+ // by design and only a future change to the component rules could break it.
+ Debug.Assert(IsUnderRoot(resolved, resultsDirectory), $"'{resolved}' escaped the results directory '{resultsDirectory}'.");
+
+ return resolved;
+ }
+
+ private static bool IsUnderRoot(string candidate, string root)
+ {
+ string normalizedRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(root));
+ string relative = Path.GetRelativePath(normalizedRoot, candidate);
+
+ return relative != ".."
+ && !relative.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal)
+ && !relative.StartsWith(".." + Path.AltDirectorySeparatorChar, StringComparison.Ordinal)
+ && !Path.IsPathRooted(relative);
+ }
+
+ ///
+ /// The deepest directory that contains every module, used as a stable anchor for identities.
+ /// Falls back to the working directory when the modules share nothing (for example, modules on
+ /// different drives).
+ ///
+ private static string GetCommonRootDirectory(List modules, string workingDirectory)
+ {
+ string? commonRoot = null;
+ foreach (TestModule module in modules)
+ {
+ string? moduleDirectory = Path.GetDirectoryName(GetProjectPath(module, workingDirectory));
+ if (string.IsNullOrEmpty(moduleDirectory))
+ {
+ continue;
+ }
+
+ commonRoot = commonRoot is null ? moduleDirectory : GetCommonPrefixDirectory(commonRoot, moduleDirectory);
+ if (string.IsNullOrEmpty(commonRoot))
+ {
+ return workingDirectory;
+ }
+ }
+
+ return string.IsNullOrEmpty(commonRoot) ? workingDirectory : commonRoot;
+ }
+
+ private static string GetCommonPrefixDirectory(string first, string second)
+ {
+ StringComparison comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
+
+ // Keep the filesystem root ('C:\', '/', '\\server\share\') attached. Joining bare segments
+ // would turn 'C:\foo' and 'C:\bar' into the drive-relative 'C:', whose meaning depends on
+ // the process working directory.
+ string firstRoot = Path.GetPathRoot(first) ?? string.Empty;
+ string secondRoot = Path.GetPathRoot(second) ?? string.Empty;
+ if (firstRoot.Length == 0 || !string.Equals(firstRoot, secondRoot, comparison))
+ {
+ return string.Empty;
+ }
+
+ char[] separators = [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar];
+ string[] firstSegments = first[firstRoot.Length..].Split(separators, StringSplitOptions.RemoveEmptyEntries);
+ string[] secondSegments = second[secondRoot.Length..].Split(separators, StringSplitOptions.RemoveEmptyEntries);
+
+ int shared = 0;
+ while (shared < firstSegments.Length
+ && shared < secondSegments.Length
+ && string.Equals(firstSegments[shared], secondSegments[shared], comparison))
+ {
+ shared++;
+ }
+
+ return Path.Combine(firstRoot, string.Join(Path.DirectorySeparatorChar, firstSegments, 0, shared));
+ }
+
+ ///
+ /// The project folder, defaulting to the project file name and falling back to the assembly name
+ /// when the module was discovered through --test-modules instead of a project. A short hash
+ /// is appended only when another distinct project in the same run shares the name.
+ ///
+ private string GetProjectDirectoryName(TestModule module)
+ {
+ string projectName = GetProjectName(module);
+
+ return LimitComponentLength(_ambiguousProjectNames.Contains(projectName)
+ ? $"{projectName}_{GetShortHash(GetProjectIdentity(module, _identityRoot))}"
+ : projectName);
+ }
+
+ ///
+ /// The pivot folder distinguishing runs of the same project across target frameworks and
+ /// runtimes. Multiple elements are joined by an underscore, following the artifacts layout.
+ /// The configuration is deliberately not part of the pivot: a single test run targets one
+ /// configuration, so it would only ever add a constant level to every path.
+ ///
+ private static string GetPivotDirectoryName(TestModule module)
+ {
+ string targetFramework = SanitizePathComponent(module.TargetFramework);
+ string runtime = SanitizePathComponent(GetRuntimeComponent(module));
+
+ return LimitComponentLength($"{targetFramework}_{runtime}".ToLowerInvariant());
+ }
+
+ ///
+ /// Prefers the runtime identifier the module was actually built for, so that runs differing
+ /// only by RID stay separate, and falls back to the architecture for the common case where no
+ /// runtime identifier was requested.
+ ///
+ private static string GetRuntimeComponent(TestModule module)
+ {
+ if (!string.IsNullOrEmpty(module.RunProperties.RuntimeIdentifier))
+ {
+ return module.RunProperties.RuntimeIdentifier;
+ }
+
+ return GetTargetArchitecture(module).ToString();
+ }
+
+ private static string GetProjectName(TestModule module)
+ {
+ string? projectName = string.IsNullOrEmpty(module.ProjectFullPath)
+ ? Path.GetFileNameWithoutExtension(module.TargetPath)
+ : Path.GetFileNameWithoutExtension(module.ProjectFullPath);
+
+ return SanitizePathComponent(projectName);
+ }
+
+ ///
+ /// Identifies the project a module belongs to. Modules of a multi-targeted project share an
+ /// identity so they nest under a single project folder and are separated only by their pivot.
+ ///
+ private static string GetProjectIdentity(TestModule module, string identityRoot)
+ {
+ string path = GetProjectPath(module, identityRoot);
+ if (string.IsNullOrEmpty(path))
+ {
+ return string.Empty;
+ }
+
+ string relativePath = Path.GetRelativePath(identityRoot, path)
+ .Replace(Path.DirectorySeparatorChar, '/');
+
+ return OperatingSystem.IsWindows() ? relativePath.ToLowerInvariant() : relativePath;
+ }
+
+ private static string GetProjectPath(TestModule module, string basePath)
+ {
+ string path = string.IsNullOrEmpty(module.ProjectFullPath) ? module.TargetPath : module.ProjectFullPath;
+
+ return string.IsNullOrEmpty(path) ? string.Empty : Path.GetFullPath(path, basePath);
+ }
+
+ private static string GetShortHash(string value)
+ {
+ byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(value));
+ return Convert.ToHexString(hash.AsSpan(0, 8)).ToLowerInvariant();
+ }
+
+ private static Architecture GetTargetArchitecture(TestModule module)
+ {
+ if (EnvironmentVariableNames.TryParseArchitecture(module.RunProperties.RuntimeIdentifier, out Architecture architecture)
+ || EnvironmentVariableNames.TryParseArchitecture(module.RunProperties.DefaultAppHostRuntimeIdentifier, out architecture))
+ {
+ return architecture;
+ }
+
+ return RuntimeInformation.ProcessArchitecture;
+ }
+
+ private static string SanitizePathComponent(string? value)
+ {
+ if (string.IsNullOrEmpty(value))
+ {
+ return UnknownComponent;
+ }
+
+ StringBuilder builder = new(value.Length);
+ foreach (char character in value)
+ {
+ builder.Append(s_invalidPathComponentCharacters.Contains(character) ? '_' : character);
+ }
+
+ string sanitized = builder.ToString();
+
+ // A project named '...csproj' yields '..', which would otherwise walk out of the results
+ // directory. Trailing dots and spaces are also not addressable on Windows.
+ string trimmed = sanitized.TrimEnd('.', ' ');
+
+ return trimmed.Length == 0 ? UnknownComponent : trimmed;
+ }
+
+ ///
+ /// Keeps a single directory component within the limit common to Windows and Linux
+ /// filesystems, so that a long project name (or a long name plus its disambiguating suffix)
+ /// cannot make the test application fail to create its results directory.
+ ///
+ private static string LimitComponentLength(string component)
+ {
+ if (component.Length <= MaxPathComponentLength)
+ {
+ return component;
+ }
+
+ // The appended hash is computed over the full component, so truncated names stay unique.
+ string hash = GetShortHash(component);
+ return string.Concat(component.AsSpan(0, MaxPathComponentLength - hash.Length - 1), "_", hash);
+ }
+}
diff --git a/test/TestAssets/TestProjects/MultiTestProjectSolutionWithDuplicateProjectNames/MultiTestProjectSolutionWithDuplicateProjectNames.slnx b/test/TestAssets/TestProjects/MultiTestProjectSolutionWithDuplicateProjectNames/MultiTestProjectSolutionWithDuplicateProjectNames.slnx
new file mode 100644
index 000000000000..aeccfd9a07ee
--- /dev/null
+++ b/test/TestAssets/TestProjects/MultiTestProjectSolutionWithDuplicateProjectNames/MultiTestProjectSolutionWithDuplicateProjectNames.slnx
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/test/TestAssets/TestProjects/MultiTestProjectSolutionWithDuplicateProjectNames/global.json b/test/TestAssets/TestProjects/MultiTestProjectSolutionWithDuplicateProjectNames/global.json
new file mode 100644
index 000000000000..9009caf0ba8f
--- /dev/null
+++ b/test/TestAssets/TestProjects/MultiTestProjectSolutionWithDuplicateProjectNames/global.json
@@ -0,0 +1,5 @@
+{
+ "test": {
+ "runner": "Microsoft.Testing.Platform"
+ }
+}
diff --git a/test/TestAssets/TestProjects/MultiTestProjectSolutionWithDuplicateProjectNames/samples/Tests/Program.cs b/test/TestAssets/TestProjects/MultiTestProjectSolutionWithDuplicateProjectNames/samples/Tests/Program.cs
new file mode 100644
index 000000000000..05bfea68b3f1
--- /dev/null
+++ b/test/TestAssets/TestProjects/MultiTestProjectSolutionWithDuplicateProjectNames/samples/Tests/Program.cs
@@ -0,0 +1,59 @@
+using Microsoft.Testing.Platform.Builder;
+using Microsoft.Testing.Platform.Capabilities.TestFramework;
+using Microsoft.Testing.Platform.Configurations;
+using Microsoft.Testing.Platform.Extensions.Messages;
+using Microsoft.Testing.Platform.Extensions.TestFramework;
+using Microsoft.Testing.Platform.Services;
+
+var testApplicationBuilder = await TestApplication.CreateBuilderAsync(args);
+
+testApplicationBuilder.RegisterTestFramework(
+ _ => new TestFrameworkCapabilities(),
+ (_, serviceProvider) => new DummyTestAdapter(serviceProvider));
+
+using var testApplication = await testApplicationBuilder.BuildAsync();
+return await testApplication.RunAsync();
+
+public class DummyTestAdapter(IServiceProvider serviceProvider) : ITestFramework, IDataProducer
+{
+ // Every project in this solution writes this same relative file name into its test results
+ // directory, the way a coverage or TRX report with a relative path does. With a shared results
+ // directory the projects overwrite each other; with a per-module layout both reports survive.
+ private const string ReportFileName = "report.txt";
+
+ 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 => new[] {
+ typeof(TestNodeUpdateMessage)
+ };
+
+ 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)
+ {
+ string resultDirectory = serviceProvider.GetConfiguration().GetTestResultDirectory();
+ Directory.CreateDirectory(resultDirectory);
+ File.WriteAllText(Path.Combine(resultDirectory, ReportFileName), "samples");
+
+ await context.MessageBus.PublishAsync(this, new TestNodeUpdateMessage(context.Request.Session.SessionUid, new TestNode()
+ {
+ Uid = "Test1",
+ DisplayName = "Test1",
+ Properties = new PropertyBag(new PassedTestNodeStateProperty("OK")),
+ }));
+
+ context.Complete();
+ }
+}
diff --git a/test/TestAssets/TestProjects/MultiTestProjectSolutionWithDuplicateProjectNames/samples/Tests/Tests.csproj b/test/TestAssets/TestProjects/MultiTestProjectSolutionWithDuplicateProjectNames/samples/Tests/Tests.csproj
new file mode 100644
index 000000000000..d02beca2c7e8
--- /dev/null
+++ b/test/TestAssets/TestProjects/MultiTestProjectSolutionWithDuplicateProjectNames/samples/Tests/Tests.csproj
@@ -0,0 +1,19 @@
+
+
+
+
+ $(CurrentTargetFramework)
+ Exe
+
+ enable
+ enable
+
+ false
+ false
+ true
+
+
+
+
+
+
diff --git a/test/TestAssets/TestProjects/MultiTestProjectSolutionWithDuplicateProjectNames/src/Tests/Program.cs b/test/TestAssets/TestProjects/MultiTestProjectSolutionWithDuplicateProjectNames/src/Tests/Program.cs
new file mode 100644
index 000000000000..7ebb681458f8
--- /dev/null
+++ b/test/TestAssets/TestProjects/MultiTestProjectSolutionWithDuplicateProjectNames/src/Tests/Program.cs
@@ -0,0 +1,59 @@
+using Microsoft.Testing.Platform.Builder;
+using Microsoft.Testing.Platform.Capabilities.TestFramework;
+using Microsoft.Testing.Platform.Configurations;
+using Microsoft.Testing.Platform.Extensions.Messages;
+using Microsoft.Testing.Platform.Extensions.TestFramework;
+using Microsoft.Testing.Platform.Services;
+
+var testApplicationBuilder = await TestApplication.CreateBuilderAsync(args);
+
+testApplicationBuilder.RegisterTestFramework(
+ _ => new TestFrameworkCapabilities(),
+ (_, serviceProvider) => new DummyTestAdapter(serviceProvider));
+
+using var testApplication = await testApplicationBuilder.BuildAsync();
+return await testApplication.RunAsync();
+
+public class DummyTestAdapter(IServiceProvider serviceProvider) : ITestFramework, IDataProducer
+{
+ // Every project in this solution writes this same relative file name into its test results
+ // directory, the way a coverage or TRX report with a relative path does. With a shared results
+ // directory the projects overwrite each other; with a per-module layout both reports survive.
+ private const string ReportFileName = "report.txt";
+
+ 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 => new[] {
+ typeof(TestNodeUpdateMessage)
+ };
+
+ 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)
+ {
+ string resultDirectory = serviceProvider.GetConfiguration().GetTestResultDirectory();
+ Directory.CreateDirectory(resultDirectory);
+ File.WriteAllText(Path.Combine(resultDirectory, ReportFileName), "src");
+
+ await context.MessageBus.PublishAsync(this, new TestNodeUpdateMessage(context.Request.Session.SessionUid, new TestNode()
+ {
+ Uid = "Test1",
+ DisplayName = "Test1",
+ Properties = new PropertyBag(new PassedTestNodeStateProperty("OK")),
+ }));
+
+ context.Complete();
+ }
+}
diff --git a/test/TestAssets/TestProjects/MultiTestProjectSolutionWithDuplicateProjectNames/src/Tests/Tests.csproj b/test/TestAssets/TestProjects/MultiTestProjectSolutionWithDuplicateProjectNames/src/Tests/Tests.csproj
new file mode 100644
index 000000000000..d02beca2c7e8
--- /dev/null
+++ b/test/TestAssets/TestProjects/MultiTestProjectSolutionWithDuplicateProjectNames/src/Tests/Tests.csproj
@@ -0,0 +1,19 @@
+
+
+
+
+ $(CurrentTargetFramework)
+ Exe
+
+ enable
+ enable
+
+ false
+ false
+ true
+
+
+
+
+
+
diff --git a/test/TestAssets/TestProjects/MultiTestProjectSolutionWithSharedReportName/MultiTestProjectSolutionWithSharedReportName.sln b/test/TestAssets/TestProjects/MultiTestProjectSolutionWithSharedReportName/MultiTestProjectSolutionWithSharedReportName.sln
new file mode 100644
index 000000000000..754d4f06e2d7
--- /dev/null
+++ b/test/TestAssets/TestProjects/MultiTestProjectSolutionWithSharedReportName/MultiTestProjectSolutionWithSharedReportName.sln
@@ -0,0 +1,28 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.12.35322.30 main
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestProjectA", "TestProjectA\TestProjectA.csproj", "{2B7B0D3C-6C36-4C5E-9E3E-9C0F2D5A1A01}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestProjectB", "TestProjectB\TestProjectB.csproj", "{2B7B0D3C-6C36-4C5E-9E3E-9C0F2D5A1A02}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {2B7B0D3C-6C36-4C5E-9E3E-9C0F2D5A1A01}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {2B7B0D3C-6C36-4C5E-9E3E-9C0F2D5A1A01}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {2B7B0D3C-6C36-4C5E-9E3E-9C0F2D5A1A01}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {2B7B0D3C-6C36-4C5E-9E3E-9C0F2D5A1A01}.Release|Any CPU.Build.0 = Release|Any CPU
+ {2B7B0D3C-6C36-4C5E-9E3E-9C0F2D5A1A02}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {2B7B0D3C-6C36-4C5E-9E3E-9C0F2D5A1A02}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {2B7B0D3C-6C36-4C5E-9E3E-9C0F2D5A1A02}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {2B7B0D3C-6C36-4C5E-9E3E-9C0F2D5A1A02}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+EndGlobal
diff --git a/test/TestAssets/TestProjects/MultiTestProjectSolutionWithSharedReportName/TestProjectA/Program.cs b/test/TestAssets/TestProjects/MultiTestProjectSolutionWithSharedReportName/TestProjectA/Program.cs
new file mode 100644
index 000000000000..6785889b0991
--- /dev/null
+++ b/test/TestAssets/TestProjects/MultiTestProjectSolutionWithSharedReportName/TestProjectA/Program.cs
@@ -0,0 +1,59 @@
+using Microsoft.Testing.Platform.Builder;
+using Microsoft.Testing.Platform.Capabilities.TestFramework;
+using Microsoft.Testing.Platform.Configurations;
+using Microsoft.Testing.Platform.Extensions.Messages;
+using Microsoft.Testing.Platform.Extensions.TestFramework;
+using Microsoft.Testing.Platform.Services;
+
+var testApplicationBuilder = await TestApplication.CreateBuilderAsync(args);
+
+testApplicationBuilder.RegisterTestFramework(
+ _ => new TestFrameworkCapabilities(),
+ (_, serviceProvider) => new DummyTestAdapter(serviceProvider));
+
+using var testApplication = await testApplicationBuilder.BuildAsync();
+return await testApplication.RunAsync();
+
+public class DummyTestAdapter(IServiceProvider serviceProvider) : ITestFramework, IDataProducer
+{
+ // Every project in this solution writes this same relative file name into its test results
+ // directory, the way a coverage or TRX report with a relative path does. With a shared results
+ // directory the projects overwrite each other; with a per-module layout both reports survive.
+ private const string ReportFileName = "report.txt";
+
+ 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 => new[] {
+ typeof(TestNodeUpdateMessage)
+ };
+
+ 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)
+ {
+ string resultDirectory = serviceProvider.GetConfiguration().GetTestResultDirectory();
+ Directory.CreateDirectory(resultDirectory);
+ File.WriteAllText(Path.Combine(resultDirectory, ReportFileName), "TestProjectA");
+
+ await context.MessageBus.PublishAsync(this, new TestNodeUpdateMessage(context.Request.Session.SessionUid, new TestNode()
+ {
+ Uid = "Test1",
+ DisplayName = "Test1",
+ Properties = new PropertyBag(new PassedTestNodeStateProperty("OK")),
+ }));
+
+ context.Complete();
+ }
+}
diff --git a/test/TestAssets/TestProjects/MultiTestProjectSolutionWithSharedReportName/TestProjectA/TestProjectA.csproj b/test/TestAssets/TestProjects/MultiTestProjectSolutionWithSharedReportName/TestProjectA/TestProjectA.csproj
new file mode 100644
index 000000000000..d02beca2c7e8
--- /dev/null
+++ b/test/TestAssets/TestProjects/MultiTestProjectSolutionWithSharedReportName/TestProjectA/TestProjectA.csproj
@@ -0,0 +1,19 @@
+
+
+
+
+ $(CurrentTargetFramework)
+ Exe
+
+ enable
+ enable
+
+ false
+ false
+ true
+
+
+
+
+
+
diff --git a/test/TestAssets/TestProjects/MultiTestProjectSolutionWithSharedReportName/TestProjectB/Program.cs b/test/TestAssets/TestProjects/MultiTestProjectSolutionWithSharedReportName/TestProjectB/Program.cs
new file mode 100644
index 000000000000..e70774a00bf9
--- /dev/null
+++ b/test/TestAssets/TestProjects/MultiTestProjectSolutionWithSharedReportName/TestProjectB/Program.cs
@@ -0,0 +1,59 @@
+using Microsoft.Testing.Platform.Builder;
+using Microsoft.Testing.Platform.Capabilities.TestFramework;
+using Microsoft.Testing.Platform.Configurations;
+using Microsoft.Testing.Platform.Extensions.Messages;
+using Microsoft.Testing.Platform.Extensions.TestFramework;
+using Microsoft.Testing.Platform.Services;
+
+var testApplicationBuilder = await TestApplication.CreateBuilderAsync(args);
+
+testApplicationBuilder.RegisterTestFramework(
+ _ => new TestFrameworkCapabilities(),
+ (_, serviceProvider) => new DummyTestAdapter(serviceProvider));
+
+using var testApplication = await testApplicationBuilder.BuildAsync();
+return await testApplication.RunAsync();
+
+public class DummyTestAdapter(IServiceProvider serviceProvider) : ITestFramework, IDataProducer
+{
+ // Every project in this solution writes this same relative file name into its test results
+ // directory, the way a coverage or TRX report with a relative path does. With a shared results
+ // directory the projects overwrite each other; with a per-module layout both reports survive.
+ private const string ReportFileName = "report.txt";
+
+ 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 => new[] {
+ typeof(TestNodeUpdateMessage)
+ };
+
+ 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)
+ {
+ string resultDirectory = serviceProvider.GetConfiguration().GetTestResultDirectory();
+ Directory.CreateDirectory(resultDirectory);
+ File.WriteAllText(Path.Combine(resultDirectory, ReportFileName), "TestProjectB");
+
+ await context.MessageBus.PublishAsync(this, new TestNodeUpdateMessage(context.Request.Session.SessionUid, new TestNode()
+ {
+ Uid = "Test1",
+ DisplayName = "Test1",
+ Properties = new PropertyBag(new PassedTestNodeStateProperty("OK")),
+ }));
+
+ context.Complete();
+ }
+}
diff --git a/test/TestAssets/TestProjects/MultiTestProjectSolutionWithSharedReportName/TestProjectB/TestProjectB.csproj b/test/TestAssets/TestProjects/MultiTestProjectSolutionWithSharedReportName/TestProjectB/TestProjectB.csproj
new file mode 100644
index 000000000000..d02beca2c7e8
--- /dev/null
+++ b/test/TestAssets/TestProjects/MultiTestProjectSolutionWithSharedReportName/TestProjectB/TestProjectB.csproj
@@ -0,0 +1,19 @@
+
+
+
+
+ $(CurrentTargetFramework)
+ Exe
+
+ enable
+ enable
+
+ false
+ false
+ true
+
+
+
+
+
+
diff --git a/test/TestAssets/TestProjects/MultiTestProjectSolutionWithSharedReportName/global.json b/test/TestAssets/TestProjects/MultiTestProjectSolutionWithSharedReportName/global.json
new file mode 100644
index 000000000000..9009caf0ba8f
--- /dev/null
+++ b/test/TestAssets/TestProjects/MultiTestProjectSolutionWithSharedReportName/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 623a6f3e1228..148a95bf6741 100644
--- a/test/dotnet.Tests/CommandTests/Test/GivenDotnetTestBuildsAndRunsTests.cs
+++ b/test/dotnet.Tests/CommandTests/Test/GivenDotnetTestBuildsAndRunsTests.cs
@@ -268,6 +268,94 @@ public void RunMultipleTestProjectsWithFailingTests_ShouldReturnExitCodeAtLeastO
result.ExitCode.Should().Be(ExitCodes.AtLeastOneTestFailed);
}
+ [Fact]
+ public void RunMultipleTestProjectsWithPerModuleResultsDirectoryLayout_ShouldCreateSeparateDirectories()
+ {
+ TestAsset testInstance = _testAssetsManager.CopyTestAsset("MultiTestProjectSolutionWithTests", Guid.NewGuid().ToString())
+ .WithSource();
+ string resultsDirectory = Path.Combine(testInstance.Path, "TestResults");
+
+ CommandResult result = new DotnetTestCommand(Log, disableNewOutput: false)
+ .WithWorkingDirectory(testInstance.Path)
+ .Execute(
+ "-c", TestingConstants.Debug,
+ "--results-directory", resultsDirectory,
+ "--results-directory-layout", "per-module");
+
+ result.ExitCode.Should().Be(ExitCodes.AtLeastOneTestFailed);
+
+ Directory.GetDirectories(resultsDirectory).Select(Path.GetFileName)
+ .Should().BeEquivalentTo(["TestProject", "OtherTestProject"]);
+ foreach (string projectDirectory in Directory.GetDirectories(resultsDirectory))
+ {
+ Directory.GetDirectories(projectDirectory).Select(Path.GetFileName)
+ .Should().ContainSingle().Which.Should().MatchRegex(@"^net\d+\.\d+_[a-z0-9\-\.]+$");
+ }
+ }
+
+ [Fact]
+ public void RunMultipleTestProjectsWritingTheSameReportName_ShouldOverwriteWithFlatLayout()
+ {
+ TestAsset testInstance = _testAssetsManager.CopyTestAsset("MultiTestProjectSolutionWithSharedReportName", Guid.NewGuid().ToString())
+ .WithSource();
+ string resultsDirectory = Path.Combine(testInstance.Path, "TestResults");
+
+ CommandResult result = new DotnetTestCommand(Log, disableNewOutput: false)
+ .WithWorkingDirectory(testInstance.Path)
+ .Execute(
+ "-c", TestingConstants.Debug,
+ "--results-directory", resultsDirectory,
+ "--max-parallel-test-modules", "1");
+
+ result.ExitCode.Should().Be(ExitCodes.Success);
+ Directory.GetFiles(resultsDirectory, "report.txt", SearchOption.AllDirectories)
+ .Should().ContainSingle("both projects write into the same directory with the flat layout");
+ }
+
+ [Fact]
+ public void RunMultipleTestProjectsWritingTheSameReportName_ShouldKeepBothWithPerModuleLayout()
+ {
+ TestAsset testInstance = _testAssetsManager.CopyTestAsset("MultiTestProjectSolutionWithSharedReportName", Guid.NewGuid().ToString())
+ .WithSource();
+ string resultsDirectory = Path.Combine(testInstance.Path, "TestResults");
+
+ CommandResult result = new DotnetTestCommand(Log, disableNewOutput: false)
+ .WithWorkingDirectory(testInstance.Path)
+ .Execute(
+ "-c", TestingConstants.Debug,
+ "--results-directory", resultsDirectory,
+ "--results-directory-layout", "per-module");
+
+ result.ExitCode.Should().Be(ExitCodes.Success);
+
+ string[] reports = Directory.GetFiles(resultsDirectory, "report.txt", SearchOption.AllDirectories);
+ reports.Should().HaveCount(2, "each project writes its report into its own directory");
+ reports.Select(File.ReadAllText).Should().BeEquivalentTo(["TestProjectA", "TestProjectB"]);
+ }
+
+ [Fact]
+ public void RunTestProjectsWithTheSameNameAndPerModuleLayout_ShouldDisambiguateAndKeepBothReports()
+ {
+ TestAsset testInstance = _testAssetsManager.CopyTestAsset("MultiTestProjectSolutionWithDuplicateProjectNames", Guid.NewGuid().ToString())
+ .WithSource();
+ string resultsDirectory = Path.Combine(testInstance.Path, "TestResults");
+
+ CommandResult result = new DotnetTestCommand(Log, disableNewOutput: false)
+ .WithWorkingDirectory(testInstance.Path)
+ .Execute(
+ "-c", TestingConstants.Debug,
+ "--results-directory-layout", "per-module");
+
+ result.ExitCode.Should().Be(ExitCodes.Success);
+
+ Directory.GetDirectories(resultsDirectory).Select(Path.GetFileName)
+ .Should().HaveCount(2).And.AllSatisfy(name => name.Should().MatchRegex("^Tests_[0-9a-f]{16}$"));
+
+ string[] reports = Directory.GetFiles(resultsDirectory, "report.txt", SearchOption.AllDirectories);
+ reports.Should().HaveCount(2);
+ reports.Select(File.ReadAllText).Should().BeEquivalentTo(["src", "samples"]);
+ }
+
[InlineData(TestingConstants.Debug)]
[InlineData(TestingConstants.Release)]
[Theory]
diff --git a/test/dotnet.Tests/CommandTests/Test/TestCommandParserTests.cs b/test/dotnet.Tests/CommandTests/Test/TestCommandParserTests.cs
index 0f9a4f325cd0..5ce88fb3e8ed 100644
--- a/test/dotnet.Tests/CommandTests/Test/TestCommandParserTests.cs
+++ b/test/dotnet.Tests/CommandTests/Test/TestCommandParserTests.cs
@@ -67,6 +67,30 @@ public void VSTestCommandIncludesPropertiesOption()
propertyOption.Aliases.Should().Contain("/p", "CreatePropertyOption should include /p alias for MSBuild compatibility");
}
+ [Theory]
+ [InlineData(null, nameof(ResultsDirectoryLayout.Flat))]
+ [InlineData("flat", nameof(ResultsDirectoryLayout.Flat))]
+ [InlineData("per-module", nameof(ResultsDirectoryLayout.PerModule))]
+ public void MTPCommandParsesResultsDirectoryLayout(string? value, string expected)
+ {
+ var command = new TestCommandDefinition.MicrosoftTestingPlatform();
+ var parseResult = value is null
+ ? command.Parse([])
+ : command.Parse(["--results-directory-layout", value]);
+
+ parseResult.Errors.Should().BeEmpty();
+ MSBuildUtility.GetBuildOptions(parseResult).PathOptions.ResultsDirectoryLayout.ToString().Should().Be(expected);
+ }
+
+ [Fact]
+ public void MTPCommandRejectsInvalidResultsDirectoryLayout()
+ {
+ var command = new TestCommandDefinition.MicrosoftTestingPlatform();
+ var parseResult = command.Parse(["--results-directory-layout", "invalid"]);
+
+ parseResult.Errors.Should().NotBeEmpty();
+ }
+
[Fact]
public void DllDetectionShouldExcludeRunArgumentsAndGlobalProperties()
{
diff --git a/test/dotnet.Tests/CommandTests/Test/TestResultsDirectoryResolverTests.cs b/test/dotnet.Tests/CommandTests/Test/TestResultsDirectoryResolverTests.cs
new file mode 100644
index 000000000000..0aeca1c617a5
--- /dev/null
+++ b/test/dotnet.Tests/CommandTests/Test/TestResultsDirectoryResolverTests.cs
@@ -0,0 +1,207 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.DotNet.Cli.Commands.Run;
+using Microsoft.DotNet.Cli.Commands.Test;
+
+namespace dotnet.Tests.CommandTests.Test;
+
+public class TestResultsDirectoryResolverTests
+{
+ [Fact]
+ public void ResolveReturnsConfiguredDirectoryForFlatLayout()
+ {
+ string resultsDirectory = Path.GetFullPath("results");
+ TestModule module = CreateModule();
+ TestResultsDirectoryResolver resolver = CreateResolver(resultsDirectory, ResultsDirectoryLayout.Flat, module);
+
+ resolver.Resolve(module).Should().Be(resultsDirectory);
+ }
+
+ [Fact]
+ public void ResolveReturnsNullForFlatLayoutWithoutConfiguredDirectory()
+ {
+ TestModule module = CreateModule();
+ TestResultsDirectoryResolver resolver = CreateResolver(null, ResultsDirectoryLayout.Flat, module);
+
+ resolver.Resolve(module).Should().BeNull();
+ }
+
+ [Fact]
+ public void ResolveCreatesProjectAndPivotDirectoriesUnderConfiguredRoot()
+ {
+ string resultsDirectory = Path.Combine(WorkingDirectory, "artifacts");
+ TestModule module = CreateModule("ProjectA");
+ TestResultsDirectoryResolver resolver = CreateResolver(resultsDirectory, ResultsDirectoryLayout.PerModule, module);
+
+ string first = resolver.Resolve(module)!;
+ string second = resolver.Resolve(module)!;
+
+ first.Should().Be(second);
+ first.Should().Be(Path.Combine(resultsDirectory, "ProjectA", "net10.0_x64"));
+ }
+
+ [Fact]
+ public void ResolveUsesDefaultRootForPerModuleLayout()
+ {
+ TestModule module = CreateModule("ProjectA");
+ TestResultsDirectoryResolver resolver = CreateResolver(null, ResultsDirectoryLayout.PerModule, module);
+
+ resolver.Resolve(module).Should().Be(Path.Combine(WorkingDirectory, "TestResults", "ProjectA", "net10.0_x64"));
+ }
+
+ [Fact]
+ public void ResolveNestsTargetFrameworksOfTheSameProjectUnderOneProjectDirectory()
+ {
+ TestModule net10 = CreateModule("ProjectA");
+ TestModule net9 = CreateModule("ProjectA") with { TargetFramework = "net9.0" };
+ TestResultsDirectoryResolver resolver = CreateResolver(null, ResultsDirectoryLayout.PerModule, net10, net9);
+
+ string first = resolver.Resolve(net10)!;
+ string second = resolver.Resolve(net9)!;
+
+ Path.GetDirectoryName(first).Should().Be(Path.Combine(WorkingDirectory, "TestResults", "ProjectA"));
+ Path.GetDirectoryName(first).Should().Be(Path.GetDirectoryName(second));
+ Path.GetFileName(first).Should().Be("net10.0_x64");
+ Path.GetFileName(second).Should().Be("net9.0_x64");
+ }
+
+ [Fact]
+ public void ResolveKeepsProjectDirectoryCleanWhenProjectNamesAreUnique()
+ {
+ TestModule projectA = CreateModule("ProjectA");
+ TestModule projectB = CreateModule("ProjectB");
+ TestResultsDirectoryResolver resolver = CreateResolver(null, ResultsDirectoryLayout.PerModule, projectA, projectB);
+
+ resolver.Resolve(projectA).Should().Be(Path.Combine(WorkingDirectory, "TestResults", "ProjectA", "net10.0_x64"));
+ resolver.Resolve(projectB).Should().Be(Path.Combine(WorkingDirectory, "TestResults", "ProjectB", "net10.0_x64"));
+ }
+
+ [Fact]
+ public void ResolveDisambiguatesDistinctProjectsThatShareAName()
+ {
+ // Two different 'Tests.csproj' files in one run would otherwise clobber each other.
+ TestModule first = CreateModule("Tests", parentDirectory: "src");
+ TestModule second = CreateModule("Tests", parentDirectory: "samples");
+ TestResultsDirectoryResolver resolver = CreateResolver(null, ResultsDirectoryLayout.PerModule, first, second);
+
+ string firstPath = resolver.Resolve(first)!;
+ string secondPath = resolver.Resolve(second)!;
+
+ firstPath.Should().NotBe(secondPath);
+ Path.GetFileName(Path.GetDirectoryName(firstPath)).Should().MatchRegex("^Tests_[0-9a-f]{16}$");
+ Path.GetFileName(Path.GetDirectoryName(secondPath)).Should().MatchRegex("^Tests_[0-9a-f]{16}$");
+ Path.GetFileName(firstPath).Should().Be("net10.0_x64");
+ }
+
+ [Fact]
+ public void ResolveIsStableAcrossResolverInstancesForTheSameModuleSet()
+ {
+ TestModule first = CreateModule("Tests", parentDirectory: "src");
+ TestModule second = CreateModule("Tests", parentDirectory: "samples");
+
+ string firstRun = CreateResolver(null, ResultsDirectoryLayout.PerModule, first, second).Resolve(first)!;
+ string secondRun = CreateResolver(null, ResultsDirectoryLayout.PerModule, first, second).Resolve(first)!;
+
+ firstRun.Should().Be(secondRun);
+ }
+
+ [Fact]
+ public void ResolveKeepsProjectsWithDottedNamesInsideTheResultsRoot()
+ {
+ // Path.GetFileNameWithoutExtension("...csproj") is "..", which must never be used as a
+ // path component or the results would be written outside the results directory.
+ TestModule module = CreateModule("..");
+ TestResultsDirectoryResolver resolver = CreateResolver(null, ResultsDirectoryLayout.PerModule, module);
+
+ string actual = resolver.Resolve(module)!;
+
+ string root = Path.Combine(WorkingDirectory, "TestResults");
+ actual.Should().StartWith(root + Path.DirectorySeparatorChar);
+ Path.GetFileName(Path.GetDirectoryName(actual)).Should().NotBe("..");
+ }
+
+ [Fact]
+ public void ResolveIsIndependentOfTheCurrentDirectory()
+ {
+ // The same solution must produce the same folder names no matter where dotnet test ran.
+ TestModule first = CreateModule("Tests", parentDirectory: "src");
+ TestModule second = CreateModule("Tests", parentDirectory: "samples");
+
+ string fromRepoRoot = CreateResolver(null, ResultsDirectoryLayout.PerModule, WorkingDirectory, first, second).Resolve(first)!;
+ string fromElsewhere = CreateResolver(null, ResultsDirectoryLayout.PerModule, Path.Combine(WorkingDirectory, "src"), first, second).Resolve(first)!;
+
+ Path.GetFileName(Path.GetDirectoryName(fromRepoRoot))
+ .Should().Be(Path.GetFileName(Path.GetDirectoryName(fromElsewhere)));
+ }
+
+ [Fact]
+ public void ResolveUsesRuntimeIdentifierInPivotWhenOneWasRequested()
+ {
+ TestModule module = CreateModule("ProjectA", runtimeIdentifier: "linux-musl-arm64");
+ TestResultsDirectoryResolver resolver = CreateResolver(null, ResultsDirectoryLayout.PerModule, module);
+
+ Path.GetFileName(resolver.Resolve(module)).Should().Be("net10.0_linux-musl-arm64");
+ }
+
+ [Fact]
+ public void ResolveFallsBackToAssemblyNameWhenModuleHasNoProjectMetadata()
+ {
+ string targetPath = Path.Combine(WorkingDirectory, "bin", "DirectTests.dll");
+ TestModule module = new(
+ new RunProperties("dotnet", $"exec \"{targetPath}\"", null),
+ ProjectFullPath: null,
+ TargetFramework: null,
+ IsTestingPlatformApplication: true,
+ LaunchSettings: null,
+ TargetPath: targetPath,
+ DotnetRootArchVariableName: null);
+ TestResultsDirectoryResolver resolver = CreateResolver(null, ResultsDirectoryLayout.PerModule, module);
+
+ string actual = resolver.Resolve(module)!;
+
+ Path.GetDirectoryName(actual).Should().Be(Path.Combine(WorkingDirectory, "TestResults", "DirectTests"));
+ Path.GetFileName(actual).Should().MatchRegex("^unknown_[a-z0-9]+$");
+ }
+
+ private static string WorkingDirectory => Path.GetFullPath("repo");
+
+ private static TestResultsDirectoryResolver CreateResolver(string? resultsDirectory, ResultsDirectoryLayout layout, params TestModule[] modules)
+ => CreateResolver(resultsDirectory, layout, WorkingDirectory, modules);
+
+ private static TestResultsDirectoryResolver CreateResolver(string? resultsDirectory, ResultsDirectoryLayout layout, string workingDirectory, params TestModule[] modules)
+ => TestResultsDirectoryResolver.Create(
+ new PathOptions(
+ ProjectOrSolutionPath: null,
+ SolutionPath: null,
+ TestModules: null,
+ ResultsDirectoryPath: resultsDirectory,
+ ResultsDirectoryLayout: layout,
+ ConfigFilePath: null,
+ DiagnosticOutputDirectoryPath: null),
+ modules,
+ workingDirectory);
+
+ private static TestModule CreateModule(string projectName = "ProjectA", string? parentDirectory = null, string runtimeIdentifier = "")
+ {
+ string projectDirectory = parentDirectory is null
+ ? Path.Combine(WorkingDirectory, projectName)
+ : Path.Combine(WorkingDirectory, parentDirectory, projectName);
+ string targetPath = Path.Combine(projectDirectory, "bin", "Debug", "net10.0", "MyTests.dll");
+
+ return new TestModule(
+ new RunProperties(
+ Command: "dotnet",
+ Arguments: targetPath,
+ WorkingDirectory: projectDirectory,
+ RuntimeIdentifier: runtimeIdentifier,
+ DefaultAppHostRuntimeIdentifier: "win-x64",
+ TargetFrameworkVersion: "v10.0"),
+ ProjectFullPath: Path.Combine(projectDirectory, $"{projectName}.csproj"),
+ TargetFramework: "net10.0",
+ IsTestingPlatformApplication: true,
+ LaunchSettings: null,
+ TargetPath: targetPath,
+ DotnetRootArchVariableName: null);
+ }
+}
diff --git a/test/dotnet.Tests/CommandTests/Test/snapshots/MTPHelpSnapshotTests.VerifyMTPHelpOutput.verified.txt b/test/dotnet.Tests/CommandTests/Test/snapshots/MTPHelpSnapshotTests.VerifyMTPHelpOutput.verified.txt
index 9d648d9613c1..822f5ce09a32 100644
--- a/test/dotnet.Tests/CommandTests/Test/snapshots/MTPHelpSnapshotTests.VerifyMTPHelpOutput.verified.txt
+++ b/test/dotnet.Tests/CommandTests/Test/snapshots/MTPHelpSnapshotTests.VerifyMTPHelpOutput.verified.txt
@@ -11,6 +11,9 @@ Options:
--root-directory The test modules have the specified root directory.
--results-directory The directory where the test results will be placed.
The specified directory will be created if it does not exist.
+ --results-directory-layout Specifies how test results are organized within the results directory.
+ 'flat' (the default) places the results of every test project directly in the results directory.
+ 'per-module' gives every test project its own '/_' subdirectory, so reports with the same file name cannot overwrite each other.
--config-file Specifies a testconfig.json file.
--diagnostic-output-directory Output directory of the diagnostic logging.
If not specified the file will be generated inside the default 'TestResults' directory.