Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
106 changes: 78 additions & 28 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 ArtifactsTestDirectoryName = "test";
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 All @@ -79,34 +93,62 @@ public static TestResultsDirectoryResolver Create(PathOptions pathOptions, IEnum
}

/// <summary>
/// A resolver that always yields the configured results directory, whatever the requested
/// layout. Used by internal invocations such as artifact post-processing, which merge results
/// across modules and so must not be scoped to a single module's directory.
/// A resolver that always yields the run-level results directory root, whatever the requested
/// layout. The root can come from an explicit results directory, artifacts output, or the
/// default results directory. Used by internal invocations such as artifact post-processing,
/// which merge results across modules and so must not be scoped to a single module's directory.
/// </summary>
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, ArtifactsTestDirectoryName);
}

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,21 +222,27 @@ 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))}"
: projectName);
}

/// <summary>
/// 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.
/// The pivot folder distinguishing runs of the same project. Artifacts output reuses the
/// evaluated <c>ArtifactsPivots</c>, including configuration and any applicable target framework
/// or runtime identifier. An explicitly requested per-module layout instead uses target
/// framework and runtime or architecture.
/// </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 +264,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_UsesArtifactsTestDirectory()
{
string artifactsDirectory = Path.Combine(Path.GetTempPath(), "artifacts");
TestModule module = CreateModule() with
{
UseArtifactsOutput = true,
ArtifactsPath = artifactsDirectory,
};
ArtifactPostProcessingJob job = CreateJob(
module,
CreateArtifact(Path.Combine(artifactsDirectory, "test", "project", "result.trx"), "microsoft.testing.trx"));

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

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

[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");

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