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_PATH ROOT_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_PATH ROOT_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_PATH ROOT_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_PATH ROOT_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_PATH ROOT_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_PATH ROOT_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_PATH ROOT_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_PATH ROOT_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_PATH ROOT_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_PATH ROOT_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_PATH ROOT_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_PATH ROOT_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_PATH ROOT_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.