Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/Cli/dotnet/Commands/Test/CliConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -151,4 +151,8 @@ internal static class ProjectProperties
internal const string BuildInParallel = "BuildInParallel";
internal const string IsTraversal = "IsTraversal";
internal const string ProjectReferenceItemName = "ProjectReference";
internal const string UseArtifactsOutput = "UseArtifactsOutput";
internal const string ArtifactsPath = "ArtifactsPath";
internal const string ArtifactsProjectName = "ArtifactsProjectName";
internal const string ArtifactsPivots = "ArtifactsPivots";
}
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,15 @@ internal static string GetOutputDirectory(BuildOptions buildOptions, ArtifactPos
return Path.GetFullPath(resultsDirectory);
}

if (job.Application.Module.UseArtifactsOutput
&& TestResultsDirectoryResolver.GetResultsDirectoryRoot(
buildOptions.PathOptions,
job.Application.Module,
Directory.GetCurrentDirectory()) is { } artifactsResultsDirectory)
{
return Path.GetFullPath(artifactsResultsDirectory);
}

ArtifactPostProcessingArtifact[] inputs =
[
.. job.Groups
Expand Down
3 changes: 2 additions & 1 deletion src/Cli/dotnet/Commands/Test/MTP/MSBuildUtility.cs
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,8 @@ public static BuildOptions GetBuildOptions(ParseResult parseResult)
? ResultsDirectoryLayout.PerModule
: ResultsDirectoryLayout.Flat,
configFile,
diagnosticOutputDirectory);
diagnosticOutputDirectory,
parseResult.HasOption(definition.ResultsDirectoryLayoutOption));

return new BuildOptions(
pathOptions,
Expand Down
6 changes: 5 additions & 1 deletion src/Cli/dotnet/Commands/Test/MTP/Models.cs
Original file line number Diff line number Diff line change
Expand Up @@ -119,4 +119,8 @@ internal sealed record TestModule(
LaunchProfile? LaunchSettings,
string TargetPath,
string? DotnetRootArchVariableName,
IReadOnlyDictionary<string, string> EnvironmentVariables);
IReadOnlyDictionary<string, string> EnvironmentVariables,
bool UseArtifactsOutput = false,
string? ArtifactsPath = null,
string? ArtifactsProjectName = null,
string? ArtifactsPivots = null);
3 changes: 2 additions & 1 deletion src/Cli/dotnet/Commands/Test/MTP/Options.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@ internal record PathOptions(
string? ResultsDirectoryPath,
ResultsDirectoryLayout ResultsDirectoryLayout,
string? ConfigFilePath,
string? DiagnosticOutputDirectoryPath);
string? DiagnosticOutputDirectoryPath,
bool ResultsDirectoryLayoutSpecified = false);

internal record BuildOptions(
PathOptions PathOptions,
Expand Down
20 changes: 19 additions & 1 deletion src/Cli/dotnet/Commands/Test/MTP/SolutionAndProjectUtility.cs
Original file line number Diff line number Diff line change
Expand Up @@ -611,7 +611,25 @@ private static (string? device, string? runtimeIdentifier) SelectDeviceForTfm(
rootVariableName = null;
}

return new TestModule(runProperties, PathUtility.FixFilePath(projectFullPath), targetFramework, isTestingPlatformApplication, launchSettings, project.GetPropertyValue(ProjectProperties.TargetPath), rootVariableName, runtimeEnvironmentVariables);
_ = bool.TryParse(project.GetPropertyValue(ProjectProperties.UseArtifactsOutput), out bool useArtifactsOutput);
string artifactsPath = project.GetPropertyValue(ProjectProperties.ArtifactsPath);
string? fullArtifactsPath = string.IsNullOrEmpty(artifactsPath)
? null
: Path.GetFullPath(PathUtility.FixFilePath(artifactsPath), project.Directory);

return new TestModule(
runProperties,
PathUtility.FixFilePath(projectFullPath),
targetFramework,
isTestingPlatformApplication,
launchSettings,
project.GetPropertyValue(ProjectProperties.TargetPath),
rootVariableName,
runtimeEnvironmentVariables,
useArtifactsOutput,
fullArtifactsPath,
project.GetPropertyValue(ProjectProperties.ArtifactsProjectName),
project.GetPropertyValue(ProjectProperties.ArtifactsPivots));

[RequiresDynamicCode("Uses MSBuild Object Model types, which are not AOT-safe")]
[UnconditionalSuppressMessage("AOT", "IL2026", Justification = "Temporary unblock for dotnet/msbuild#14064 (MSBuild build APIs are now [RequiresUnreferencedCode]). dotnet CLI runs MSBuild in-proc (not trimmed). Remove when dotnet/sdk#55225 is fixed.")]
Expand Down
91 changes: 70 additions & 21 deletions src/Cli/dotnet/Commands/Test/MTP/TestResultsDirectoryResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ namespace Microsoft.DotNet.Cli.Commands.Test;
internal sealed class TestResultsDirectoryResolver
{
private const string DefaultResultsDirectoryName = "TestResults";
private const string ArtifactsResultsDirectoryName = "test-results";
private const string UnknownComponent = "unknown";
private const int MaxPathComponentLength = 255;

Expand All @@ -32,31 +33,44 @@ internal sealed class TestResultsDirectoryResolver
private readonly string _workingDirectory;
private readonly string _identityRoot;
private readonly HashSet<string> _ambiguousProjectNames;

private TestResultsDirectoryResolver(PathOptions pathOptions, string workingDirectory, string identityRoot, HashSet<string> ambiguousProjectNames)
private readonly bool _shared;

private TestResultsDirectoryResolver(
PathOptions pathOptions,
string workingDirectory,
string identityRoot,
HashSet<string> ambiguousProjectNames,
bool shared = false)
{
_pathOptions = pathOptions;
_workingDirectory = workingDirectory;
_identityRoot = identityRoot;
_ambiguousProjectNames = ambiguousProjectNames;
_shared = shared;
}

public static TestResultsDirectoryResolver Create(PathOptions pathOptions, IEnumerable<TestModule> modules, string workingDirectory)
{
if (pathOptions.ResultsDirectoryLayout == ResultsDirectoryLayout.Flat)
List<TestModule> materializedModules = [.. modules];
List<TestModule> perModuleLayoutModules =
[
.. materializedModules.Where(module => GetResultsDirectoryLayout(pathOptions, module) == ResultsDirectoryLayout.PerModule)
];

if (perModuleLayoutModules.Count == 0)
{
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<TestModule> materializedModules = [.. modules];
string identityRoot = GetCommonRootDirectory(materializedModules, workingDirectory);
string identityRoot = GetCommonRootDirectory(perModuleLayoutModules, workingDirectory);

Dictionary<string, HashSet<string>> identitiesByProjectName = new(StringComparer.OrdinalIgnoreCase);
foreach (TestModule module in materializedModules)
foreach (TestModule module in perModuleLayoutModules)
{
string projectName = GetProjectName(module);
string projectName = GetProjectName(module, UsesArtifactsOutputDefaults(pathOptions, module));
if (!identitiesByProjectName.TryGetValue(projectName, out HashSet<string>? identities))
{
identities = new HashSet<string>(StringComparer.Ordinal);
Expand Down Expand Up @@ -84,29 +98,56 @@ public static TestResultsDirectoryResolver Create(PathOptions pathOptions, IEnum
/// across modules and so must not be scoped to a single module's directory.
/// </summary>
Comment thread
Evangelink marked this conversation as resolved.
public static TestResultsDirectoryResolver CreateShared(PathOptions pathOptions, string workingDirectory)
=> new(pathOptions with { ResultsDirectoryLayout = ResultsDirectoryLayout.Flat }, workingDirectory, workingDirectory, []);
=> new(pathOptions, workingDirectory, workingDirectory, [], shared: true);

public string? Resolve(TestModule module)
{
if (_pathOptions.ResultsDirectoryLayout == ResultsDirectoryLayout.Flat)
string? resultsDirectory = GetResultsDirectoryRoot(_pathOptions, module, _workingDirectory);
if (_shared || GetResultsDirectoryLayout(_pathOptions, module) == ResultsDirectoryLayout.Flat)
{
return _pathOptions.ResultsDirectoryPath;
return resultsDirectory;
}

string resultsDirectory = _pathOptions.ResultsDirectoryPath
?? Path.Combine(_workingDirectory, DefaultResultsDirectoryName);

string resultsRoot = resultsDirectory!;
string resolved = Path.GetFullPath(
Path.Combine(resultsDirectory, GetProjectDirectoryName(module), GetPivotDirectoryName(module)));
Path.Combine(resultsRoot, 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}'.");
Debug.Assert(IsUnderRoot(resolved, resultsRoot), $"'{resolved}' escaped the results directory '{resultsRoot}'.");

return resolved;
}

internal static string? GetResultsDirectoryRoot(PathOptions pathOptions, TestModule module, string workingDirectory)
{
if (pathOptions.ResultsDirectoryPath is { } configuredResultsDirectory)
{
return configuredResultsDirectory;
}

if (module.UseArtifactsOutput && module.ArtifactsPath is { } artifactsPath)
{
return Path.Combine(artifactsPath, ArtifactsResultsDirectoryName);
}

return GetResultsDirectoryLayout(pathOptions, module) == ResultsDirectoryLayout.PerModule
? Path.Combine(workingDirectory, DefaultResultsDirectoryName)
: null;
}

private static ResultsDirectoryLayout GetResultsDirectoryLayout(PathOptions pathOptions, TestModule module)
=> UsesArtifactsOutputDefaults(pathOptions, module)
? ResultsDirectoryLayout.PerModule
: pathOptions.ResultsDirectoryLayout;

private static bool UsesArtifactsOutputDefaults(PathOptions pathOptions, TestModule module)
=> !pathOptions.ResultsDirectoryLayoutSpecified
&& pathOptions.ResultsDirectoryPath is null
&& module.UseArtifactsOutput
&& module.ArtifactsPath is not null;

private static bool IsUnderRoot(string candidate, string root)
{
string normalizedRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(root));
Expand Down Expand Up @@ -180,7 +221,7 @@ private static string GetCommonPrefixDirectory(string first, string second)
/// </summary>
private string GetProjectDirectoryName(TestModule module)
{
string projectName = GetProjectName(module);
string projectName = GetProjectName(module, UsesArtifactsOutputDefaults(_pathOptions, module));

return LimitComponentLength(_ambiguousProjectNames.Contains(projectName)
? $"{projectName}_{GetShortHash(GetProjectIdentity(module, _identityRoot))}"
Expand All @@ -193,8 +234,14 @@ private string GetProjectDirectoryName(TestModule module)
/// 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.
/// </summary>
private static string GetPivotDirectoryName(TestModule module)
private string GetPivotDirectoryName(TestModule module)
{
if (UsesArtifactsOutputDefaults(_pathOptions, module)
&& !string.IsNullOrEmpty(module.ArtifactsPivots))
{
return LimitComponentLength(SanitizePathComponent(module.ArtifactsPivots).ToLowerInvariant());
}

string targetFramework = SanitizePathComponent(module.TargetFramework);
string runtime = SanitizePathComponent(GetRuntimeComponent(module));

Expand All @@ -216,11 +263,13 @@ private static string GetRuntimeComponent(TestModule module)
return GetTargetArchitecture(module).ToString();
}

private static string GetProjectName(TestModule module)
private static string GetProjectName(TestModule module, bool useArtifactsOutputDefaults)
{
string? projectName = string.IsNullOrEmpty(module.ProjectFullPath)
? Path.GetFileNameWithoutExtension(module.TargetPath)
: Path.GetFileNameWithoutExtension(module.ProjectFullPath);
string? projectName = useArtifactsOutputDefaults && !string.IsNullOrEmpty(module.ArtifactsProjectName)
? module.ArtifactsProjectName
: string.IsNullOrEmpty(module.ProjectFullPath)
? Path.GetFileNameWithoutExtension(module.TargetPath)
: Path.GetFileNameWithoutExtension(module.ProjectFullPath);

return SanitizePathComponent(projectName);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,26 @@ public void GetOutputDirectory_WithResultsDirectory_UsesThatDirectory()
outputDirectory.Should().Be(Path.GetFullPath(resultsDirectory));
}

[TestMethod]
public void GetOutputDirectory_WithArtifactsOutput_UsesArtifactsResultsDirectory()
{
string artifactsDirectory = Path.Combine(Path.GetTempPath(), "artifacts");
TestModule module = CreateModule() with
{
UseArtifactsOutput = true,
ArtifactsPath = artifactsDirectory,
};
ArtifactPostProcessingJob job = CreateJob(
module,
CreateArtifact(Path.Combine(artifactsDirectory, "test-results", "project", "result.trx"), "microsoft.testing.trx"));

string outputDirectory = ArtifactPostProcessingManager.GetOutputDirectory(
CreateBuildOptions(),
job);

outputDirectory.Should().Be(Path.Combine(artifactsDirectory, "test-results"));
}

[TestMethod]
public void GetOutputDirectory_WithoutResultsDirectory_PrefersDirectoryOfElectedApplicationInput()
{
Expand Down Expand Up @@ -344,8 +364,16 @@ public void GetOutputDirectory_WhenElectedApplicationProducedNoInput_UsesFirstIn
}

private static ArtifactPostProcessingJob CreateJob(params ArtifactPostProcessingArtifact[] artifacts)
=> CreateJob(CreateModule(), artifacts);

private static ArtifactPostProcessingJob CreateJob(TestModule module, params ArtifactPostProcessingArtifact[] artifacts)
{
ArtifactPostProcessingApplication application = CreateApplication();
var application = new ArtifactPostProcessingApplication(
module,
"net10.0",
"x64",
new HashSet<string>(StringComparer.Ordinal) { "microsoft.testing.trx", "microsoft.codecoverage" },
new HashSet<string>(StringComparer.Ordinal));
return new ArtifactPostProcessingJob(
application,
[new ArtifactPostProcessingGroup("microsoft.testing.trx", IsKind: true, artifacts, [application])]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,34 @@ public void RunMultipleTestProjectsWritingTheSameReportName_ShouldKeepBothWithPe
reports.Select(File.ReadAllText).Should().BeEquivalentTo(["TestProjectA", "TestProjectB"]);
}

[TestMethod]
public void RunMultipleTestProjectsWithArtifactsOutput_ShouldKeepReportsUnderArtifacts()
{
TestAsset testInstance = TestAssetsManager.CopyTestAsset("MultiTestProjectSolutionWithSharedReportName", Guid.NewGuid().ToString())
.WithSource();
File.WriteAllText(
Path.Combine(testInstance.Path, "Directory.Build.props"),
"""
<Project>
<PropertyGroup>
<UseArtifactsOutput>true</UseArtifactsOutput>
</PropertyGroup>
</Project>
""");
string resultsDirectory = Path.Combine(testInstance.Path, "artifacts", "test-results");

CommandResult result = new DotnetTestCommand(Log, disableNewOutput: false)
.WithWorkingDirectory(testInstance.Path)
.Execute("-c", TestingConstants.Debug);

result.ExitCode.Should().Be(ExitCodes.Success);

string[] reports = Directory.GetFiles(resultsDirectory, "report.txt", SearchOption.AllDirectories);
reports.Should().HaveCount(2, "artifacts output defaults to a collision-safe per-module layout");
reports.Select(File.ReadAllText).Should().BeEquivalentTo(["TestProjectA", "TestProjectB"]);
Directory.Exists(Path.Combine(testInstance.Path, "TestResults")).Should().BeFalse();
}

[TestMethod]
public void RunTestProjectsWithTheSameNameAndPerModuleLayout_ShouldDisambiguateAndKeepBothReports()
{
Expand Down
12 changes: 7 additions & 5 deletions test/dotnet.Tests/CommandTests/Test/TestCommandParserTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -632,18 +632,20 @@ public void MTPCommandRejectsInvalidListTestsFormatValue(string format)
}

[TestMethod]
[DataRow(null, nameof(ResultsDirectoryLayout.Flat))]
[DataRow("flat", nameof(ResultsDirectoryLayout.Flat))]
[DataRow("per-module", nameof(ResultsDirectoryLayout.PerModule))]
public void MTPCommandParsesResultsDirectoryLayout(string? value, string expected)
[DataRow(null, nameof(ResultsDirectoryLayout.Flat), false)]
[DataRow("flat", nameof(ResultsDirectoryLayout.Flat), true)]
[DataRow("per-module", nameof(ResultsDirectoryLayout.PerModule), true)]
public void MTPCommandParsesResultsDirectoryLayout(string? value, string expected, bool expectedSpecified)
{
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);
PathOptions pathOptions = MSBuildUtility.GetBuildOptions(parseResult).PathOptions;
pathOptions.ResultsDirectoryLayout.ToString().Should().Be(expected);
pathOptions.ResultsDirectoryLayoutSpecified.Should().Be(expectedSpecified);
}

[TestMethod]
Expand Down
Loading
Loading