diff --git a/eng/Version.Details.props b/eng/Version.Details.props index 5ee91420958f..2cb9809360e7 100644 --- a/eng/Version.Details.props +++ b/eng/Version.Details.props @@ -136,17 +136,11 @@ This file should be imported by eng/Versions.props 11.0.0-preview.7.26363.117 11.0.0-preview.7.26363.117 11.0.0-preview.7.26363.117 - - 2.4.0-preview.26367.7 + + 2.4.0-preview.26372.14 2.3.0-preview.26330.8 - 4.4.0-preview.26367.7 - 4.4.0-preview.26367.7 + 4.4.0-preview.26372.14 + 4.4.0-preview.26372.14 diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml index 83ee7ddc9dd2..2d7c7b08cdad 100644 --- a/eng/Version.Details.xml +++ b/eng/Version.Details.xml @@ -544,18 +544,18 @@ - + https://github.com/microsoft/testfx - fd744761254d82f054ebba7f8479af01f3822c5a + e40436111b52795981c425a49b4c8c2e9a70d9d8 - + https://github.com/microsoft/testfx - fd744761254d82f054ebba7f8479af01f3822c5a + e40436111b52795981c425a49b4c8c2e9a70d9d8 - + https://github.com/microsoft/testfx - fd744761254d82f054ebba7f8479af01f3822c5a + e40436111b52795981c425a49b4c8c2e9a70d9d8 https://github.com/dotnet/dotnet diff --git a/global.json b/global.json index 1320db543286..b52fe928f53b 100644 --- a/global.json +++ b/global.json @@ -29,6 +29,6 @@ "Microsoft.Build.NoTargets": "3.7.134", "Microsoft.Build.Traversal": "4.1.82", "Microsoft.WixToolset.Sdk": "6.0.3-dotnet.6", - "MSTest.Sdk": "4.4.0-preview.26367.7" + "MSTest.Sdk": "4.4.0-preview.26372.14" } } diff --git a/src/Cli/dotnet/Commands/CliCommandStrings.resx b/src/Cli/dotnet/Commands/CliCommandStrings.resx index 300daaa0169d..fb2ad47dec04 100644 --- a/src/Cli/dotnet/Commands/CliCommandStrings.resx +++ b/src/Cli/dotnet/Commands/CliCommandStrings.resx @@ -2669,6 +2669,18 @@ Proceed? The test host reported execution mode '{0}', but 'dotnet test' expected '{1}'. This typically happens when an option such as '--help', '-?' or '--list-tests' was injected into the test host through a non-CLI channel (e.g. the 'TestingPlatformCommandLineArguments' or 'RunArguments' MSBuild properties, or 'launchSettings.json' commandLineArgs). Pass these options directly to 'dotnet test' instead. {Locked="dotnet test"}{Locked="--help"}{Locked="-?"}{Locked="--list-tests"}{Locked="TestingPlatformCommandLineArguments"}{Locked="RunArguments"}{Locked="launchSettings.json"}{Locked="commandLineArgs"} + + The test host reported host type '{0}', but 'dotnet test' expected '{1}'. + {Locked="dotnet test"} + + + Artifact post-processing with '{0}' failed: {1} Original artifacts will be reported. + {0} is the test application path. {1} is the failure reason. + + + Artifact post-processing with '{0}' exited with code {1}. Any artifacts that were not merged will be reported individually. + {0} is the test application path. {1} is the process exit code. + A message of type '{0}' was received in help mode, which is not expected. diff --git a/src/Cli/dotnet/Commands/Test/CliConstants.cs b/src/Cli/dotnet/Commands/Test/CliConstants.cs index 70fe370a6e5f..486f6ef791f0 100644 --- a/src/Cli/dotnet/Commands/Test/CliConstants.cs +++ b/src/Cli/dotnet/Commands/Test/CliConstants.cs @@ -10,6 +10,8 @@ internal static class CliConstants public const string DotNetTestPipeOptionKey = "--dotnet-test-pipe"; public const string ServerOptionValue = "dotnettestcli"; + public const string ArtifactPostProcessingToolName = "internal-merge-artifacts"; + public const string ArtifactPostProcessingManifestOptionKey = "--manifest"; public const string SemiColon = ";"; @@ -77,6 +79,11 @@ internal static class HandshakeMessagePropertyNames // can belong to the same attempt. Older hosts omit it, so the SDK retains instance-based // retry inference as a compatibility fallback. internal const byte AttemptNumber = 13; + + // Semicolon-separated reverse-DNS artifact kinds and lowercase file extensions + // supported by post-processors registered in the test application. + internal const byte SupportedPostProcessorKinds = 14; + internal const byte SupportedPostProcessorExtensionsLegacy = 15; } internal static class HandshakeMessageExecutionModes @@ -89,6 +96,15 @@ internal static class HandshakeMessageExecutionModes // The test host is going to discover tests (e.g. --list-tests). internal const string Discover = "discover"; + + // The host is running a non-test tool. + internal const string Tool = "tool"; +} + +internal static class HandshakeMessageHostTypes +{ + internal const string TestHost = "TestHost"; + internal const string ArtifactPostProcessor = "ArtifactPostProcessor"; } internal static class ProtocolConstants diff --git a/src/Cli/dotnet/Commands/Test/MTP/ArtifactPostProcessingManager.cs b/src/Cli/dotnet/Commands/Test/MTP/ArtifactPostProcessingManager.cs new file mode 100644 index 000000000000..45029f9aed6b --- /dev/null +++ b/src/Cli/dotnet/Commands/Test/MTP/ArtifactPostProcessingManager.cs @@ -0,0 +1,350 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.ComponentModel; +using System.Globalization; +using System.Text.Json; +using Microsoft.DotNet.Cli.Commands.Test.IPC.Models; +using Microsoft.DotNet.Cli.Commands.Test.Terminal; + +namespace Microsoft.DotNet.Cli.Commands.Test; + +internal sealed class ArtifactPostProcessingManager +{ + private readonly Lock _lock = new(); + private readonly Dictionary _applications = []; + private readonly List _artifacts = []; + + public void RecordCapabilities( + TestModule module, + string? targetFramework, + string? architecture, + HandshakeMessage handshakeMessage) + { + string[] kinds = ParseCapabilities(handshakeMessage, HandshakeMessagePropertyNames.SupportedPostProcessorKinds); + string[] extensions = ParseCapabilities(handshakeMessage, HandshakeMessagePropertyNames.SupportedPostProcessorExtensionsLegacy) + .Select(extension => extension.ToLowerInvariant()) + .ToArray(); + + if (kinds.Length == 0 && extensions.Length == 0) + { + return; + } + + lock (_lock) + { + if (!_applications.TryGetValue(module, out ApplicationState? application)) + { + application = new ApplicationState(module, targetFramework, architecture); + _applications.Add(module, application); + } + + application.SupportedKinds.UnionWith(kinds); + application.SupportedExtensions.UnionWith(extensions); + } + } + + public void RecordArtifact( + TestModule module, + string? targetFramework, + string? architecture, + string executionId, + FileArtifactMessage artifact) + { + lock (_lock) + { + _artifacts.Add(new ArtifactPostProcessingArtifact( + artifact.FullPath!, + artifact.Kind, + module.TargetPath, + targetFramework, + architecture, + executionId)); + } + } + + public async Task ExecuteAsync( + BuildOptions buildOptions, + TerminalTestReporter output, + CtrlCCancellationManager ctrlC) + { + ArtifactPostProcessingPlan plan = ArtifactPostProcessingPlanner.Plan( + SnapshotApplications(), + SnapshotArtifacts()); + + foreach (ArtifactPostProcessingJob job in plan.Jobs) + { + if (ctrlC.Token.IsCancellationRequested) + { + break; + } + + string tempDirectory = Path.Combine( + Path.GetTempPath(), + $"dotnet-test-postproc-{Guid.NewGuid():N}"); + + try + { + Directory.CreateDirectory(tempDirectory); + string manifestPath = Path.Combine(tempDirectory, "manifest.json"); + string outputDirectory = GetOutputDirectory(buildOptions, job); + Directory.CreateDirectory(outputDirectory); + WriteManifest(manifestPath, outputDirectory, job.Groups.SelectMany(group => group.Artifacts)); + + var invocation = new ArtifactPostProcessingInvocation(manifestPath); + var toolOptions = new TestOptions( + IsHelp: false, + IsDiscovery: false, + ListTestsFormat: TestListFormat.Text, + IsArtifactPostProcessing: true); + + using var application = new TestApplication( + job.Application.Module, + buildOptions, + toolOptions, + output, + onHelpRequested: _ => { }, + artifactPostProcessingManager: this, + artifactPostProcessingInvocation: invocation); + + int exitCode = await application.RunAsync(ctrlC); + ApplyOutputs(output, job, invocation.SnapshotOutputs()); + + if (invocation.FailureMessage is { } failureMessage) + { + output.WriteWarningMessage(string.Format( + CultureInfo.CurrentCulture, + CliCommandStrings.ArtifactPostProcessingFailed, + job.Application.Module.TargetPath, + failureMessage)); + } + else if (exitCode != ExitCode.Success) + { + output.WriteWarningMessage(string.Format( + CultureInfo.CurrentCulture, + CliCommandStrings.ArtifactPostProcessingProcessFailed, + job.Application.Module.TargetPath, + exitCode)); + } + } + catch (Exception ex) when (ex is IOException + or UnauthorizedAccessException + or InvalidOperationException + or Win32Exception + or NotSupportedException + or TimeoutException) + { + output.WriteWarningMessage(string.Format( + CultureInfo.CurrentCulture, + CliCommandStrings.ArtifactPostProcessingFailed, + job.Application.Module.TargetPath, + ex.Message)); + } + finally + { + try + { + if (Directory.Exists(tempDirectory)) + { + Directory.Delete(tempDirectory, recursive: true); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + Logger.LogTrace($"Failed to clean artifact post-processing temporary directory '{tempDirectory}': {ex}"); + } + } + } + } + + internal IReadOnlyList SnapshotApplications() + { + lock (_lock) + { + return + [ + .. _applications.Values.Select(application => new ArtifactPostProcessingApplication( + application.Module, + application.TargetFramework, + application.Architecture, + new HashSet(application.SupportedKinds, StringComparer.Ordinal), + new HashSet(application.SupportedExtensions, StringComparer.Ordinal))) + ]; + } + } + + internal IReadOnlyList SnapshotArtifacts() + { + lock (_lock) + { + return [.. _artifacts]; + } + } + + private static string[] ParseCapabilities(HandshakeMessage handshakeMessage, byte propertyName) + => !handshakeMessage.Properties.TryGetValue(propertyName, out string? capabilities) + ? [] + : capabilities + .Split(CliConstants.SemiColon, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Where(capability => capability.Length > 0) + .Distinct(StringComparer.Ordinal) + .ToArray(); + + private static string GetOutputDirectory(BuildOptions buildOptions, ArtifactPostProcessingJob job) + { + if (buildOptions.PathOptions.ResultsDirectoryPath is { } resultsDirectory) + { + return Path.GetFullPath(resultsDirectory); + } + + string firstInputPath = job.Groups + .SelectMany(group => group.Artifacts) + .Select(artifact => artifact.Path) + .OrderBy(path => path, FileUtilities.PathComparer) + .First(); + + return Path.GetDirectoryName(Path.GetFullPath(firstInputPath))!; + } + + private static void WriteManifest( + string manifestPath, + string outputDirectory, + IEnumerable artifacts) + { + using FileStream stream = File.Create(manifestPath); + using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true }); + + writer.WriteStartObject(); + writer.WriteNumber("schemaVersion", 1); + writer.WriteString("outputDirectory", outputDirectory); + writer.WriteStartArray("inputs"); + foreach (ArtifactPostProcessingArtifact artifact in artifacts + .OrderBy(artifact => artifact.Path, FileUtilities.PathComparer)) + { + writer.WriteStartObject(); + writer.WriteString("path", artifact.Path); + WriteNullableString(writer, "kind", artifact.Kind); + WriteNullableString(writer, "producingTestModule", artifact.ProducingTestModule); + WriteNullableString(writer, "targetFramework", artifact.TargetFramework); + WriteNullableString(writer, "architecture", artifact.Architecture); + writer.WriteString("executionId", artifact.ExecutionId); + writer.WriteEndObject(); + } + + writer.WriteEndArray(); + writer.WriteEndObject(); + } + + private static void WriteNullableString(Utf8JsonWriter writer, string propertyName, string? value) + { + if (value is null) + { + writer.WriteNull(propertyName); + } + else + { + writer.WriteString(propertyName, value); + } + } + + internal static void ApplyOutputs( + TerminalTestReporter output, + ArtifactPostProcessingJob job, + IReadOnlyList processedArtifacts) + { + foreach (ArtifactPostProcessingArtifact processedArtifact in processedArtifacts) + { + string outputExtension = Path.GetExtension(processedArtifact.Path).ToLowerInvariant(); + // The dispatcher gives one processor both its kind-tagged inputs and matching legacy + // extension inputs, so the returned output consumes both groups. + ArtifactPostProcessingGroup[] consumedGroups = + [ + .. job.Groups.Where(group => + group.IsKind + ? string.Equals(group.Key, processedArtifact.Kind, StringComparison.Ordinal) + : string.Equals(group.Key, outputExtension, StringComparison.Ordinal)) + ]; + + if (consumedGroups.Length > 0) + { + var consumedPaths = new HashSet( + consumedGroups.SelectMany(group => group.Artifacts).Select(artifact => artifact.Path), + FileUtilities.PathComparer); + output.RemoveArtifacts(consumedPaths); + } + + output.ArtifactAdded( + outOfProcess: true, + job.Application.Module.TargetPath, + job.Application.TargetFramework, + job.Application.Architecture, + processedArtifact.ExecutionId, + testName: null, + processedArtifact.Path); + } + } + + private sealed class ApplicationState(TestModule module, string? targetFramework, string? architecture) + { + public TestModule Module { get; } = module; + public string? TargetFramework { get; } = targetFramework; + public string? Architecture { get; } = architecture; + public HashSet SupportedKinds { get; } = new(StringComparer.Ordinal); + public HashSet SupportedExtensions { get; } = new(StringComparer.Ordinal); + } +} + +internal sealed class ArtifactPostProcessingInvocation(string manifestPath) +{ + private readonly Lock _lock = new(); + private readonly List _outputs = []; + private string? _failureMessage; + + public string ManifestPath { get; } = manifestPath; + + public string? FailureMessage + { + get + { + lock (_lock) + { + return _failureMessage; + } + } + } + + public void RecordFailure(string message) + { + lock (_lock) + { + _failureMessage ??= message; + } + } + + public void RecordOutput( + TestModule module, + string? targetFramework, + string? architecture, + string executionId, + FileArtifactMessage artifact) + { + lock (_lock) + { + _outputs.Add(new ArtifactPostProcessingArtifact( + artifact.FullPath!, + artifact.Kind, + module.TargetPath, + targetFramework, + architecture, + executionId)); + } + } + + public IReadOnlyList SnapshotOutputs() + { + lock (_lock) + { + return [.. _outputs]; + } + } +} diff --git a/src/Cli/dotnet/Commands/Test/MTP/ArtifactPostProcessingPlanner.cs b/src/Cli/dotnet/Commands/Test/MTP/ArtifactPostProcessingPlanner.cs new file mode 100644 index 000000000000..7eab9a007cdc --- /dev/null +++ b/src/Cli/dotnet/Commands/Test/MTP/ArtifactPostProcessingPlanner.cs @@ -0,0 +1,162 @@ +// 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.Test.Terminal; +using NuGet.Frameworks; + +namespace Microsoft.DotNet.Cli.Commands.Test; + +internal sealed record ArtifactPostProcessingApplication( + TestModule Module, + string? TargetFramework, + string? Architecture, + IReadOnlySet SupportedKinds, + IReadOnlySet SupportedExtensions); + +internal sealed record ArtifactPostProcessingArtifact( + string Path, + string? Kind, + string ProducingTestModule, + string? TargetFramework, + string? Architecture, + string ExecutionId); + +internal sealed record ArtifactPostProcessingGroup( + string Key, + bool IsKind, + IReadOnlyList Artifacts, + IReadOnlyList Candidates); + +internal sealed record ArtifactPostProcessingJob( + ArtifactPostProcessingApplication Application, + IReadOnlyList Groups); + +internal sealed record ArtifactPostProcessingPlan(IReadOnlyList Jobs); + +internal static class ArtifactPostProcessingPlanner +{ + private const string MicrosoftCodeCoverageKind = "microsoft.codecoverage"; + private const string MicrosoftCodeCoverageExtension = ".coverage"; + + public static ArtifactPostProcessingPlan Plan( + IReadOnlyList applications, + IReadOnlyList artifacts) + { + ArtifactPostProcessingArtifact[] distinctArtifacts = + [.. artifacts.DistinctBy(artifact => artifact.Path, FileUtilities.PathComparer)]; + List groups = []; + + foreach (IGrouping group in distinctArtifacts + .Where(artifact => artifact.Kind is not null) + .GroupBy(artifact => artifact.Kind!, StringComparer.Ordinal) + .OrderBy(group => group.Key, StringComparer.Ordinal)) + { + AddGroup(group.Key, isKind: true, [.. group]); + } + + foreach (IGrouping group in distinctArtifacts + .Where(artifact => artifact.Kind is null) + .GroupBy(artifact => Path.GetExtension(artifact.Path).ToLowerInvariant(), StringComparer.Ordinal) + .Where(group => group.Key.Length > 0) + .OrderBy(group => group.Key, StringComparer.Ordinal)) + { + AddGroup(group.Key, isKind: false, [.. group]); + } + + ArtifactPostProcessingGroup[] mergeableGroups = + [ + .. groups.Where(group => + group.Artifacts.Count >= 2 + || groups.Any(other => CanCombineKindAndExtensionGroups(group, other))) + ]; + + var uncoveredGroups = new HashSet(mergeableGroups); + List jobs = []; + while (uncoveredGroups.Count > 0) + { + ArtifactPostProcessingApplication? winner = applications + .Select(application => new + { + Application = application, + Groups = uncoveredGroups.Where(group => group.Candidates.Contains(application)).ToArray(), + }) + .Where(candidate => candidate.Groups.Length > 0) + .OrderByDescending(candidate => candidate.Groups.Length) + .ThenByDescending(candidate => candidate.Groups.Sum(group => + group.Artifacts.Count(artifact => FileUtilities.PathComparer.Equals( + artifact.ProducingTestModule, + candidate.Application.Module.TargetPath)))) + .ThenByDescending(candidate => GetFrameworkVersion(candidate.Application.TargetFramework)) + .ThenBy(candidate => candidate.Application.Module.TargetPath, FileUtilities.PathComparer) + .Select(candidate => candidate.Application) + .FirstOrDefault(); + + if (winner is null) + { + break; + } + + ArtifactPostProcessingGroup[] coveredGroups = + [.. uncoveredGroups.Where(group => group.Candidates.Contains(winner))]; + jobs.Add(new ArtifactPostProcessingJob(winner, coveredGroups)); + uncoveredGroups.ExceptWith(coveredGroups); + } + + return new ArtifactPostProcessingPlan(jobs); + + void AddGroup(string key, bool isKind, ArtifactPostProcessingArtifact[] inputs) + { + ArtifactPostProcessingApplication[] candidates = + [ + .. applications.Where(application => + (isKind + ? application.SupportedKinds.Contains(key) + : application.SupportedExtensions.Contains(key)) + && IsArchitectureCompatible(key, isKind, application, inputs)) + ]; + + if (candidates.Length > 0) + { + groups.Add(new ArtifactPostProcessingGroup(key, isKind, inputs, candidates)); + } + } + } + + private static bool CanCombineKindAndExtensionGroups( + ArtifactPostProcessingGroup first, + ArtifactPostProcessingGroup second) + { + ArtifactPostProcessingGroup kindGroup = first.IsKind ? first : second; + ArtifactPostProcessingGroup extensionGroup = first.IsKind ? second : first; + + return first.IsKind != second.IsKind + && kindGroup.Artifacts.Any(artifact => + string.Equals( + Path.GetExtension(artifact.Path), + extensionGroup.Key, + StringComparison.OrdinalIgnoreCase)) + && kindGroup.Candidates.Intersect(extensionGroup.Candidates).Any() + && kindGroup.Artifacts.Count + extensionGroup.Artifacts.Count >= 2; + } + + private static bool IsArchitectureCompatible( + string key, + bool isKind, + ArtifactPostProcessingApplication application, + IReadOnlyList artifacts) + { + bool isArchitectureSensitive = isKind + ? string.Equals(key, MicrosoftCodeCoverageKind, StringComparison.Ordinal) + : string.Equals(key, MicrosoftCodeCoverageExtension, StringComparison.Ordinal); + + return !isArchitectureSensitive + || artifacts.All(artifact => + artifact.Architecture is null + || string.Equals(artifact.Architecture, application.Architecture, StringComparison.OrdinalIgnoreCase)); + } + + private static Version GetFrameworkVersion(string? targetFramework) + => string.IsNullOrEmpty(targetFramework) + ? new Version() + : NuGetFramework.ParseFolder(targetFramework).Version; +} diff --git a/src/Cli/dotnet/Commands/Test/MTP/MicrosoftTestingPlatformTestCommand.cs b/src/Cli/dotnet/Commands/Test/MTP/MicrosoftTestingPlatformTestCommand.cs index 3d029803c677..cf41b679cf66 100644 --- a/src/Cli/dotnet/Commands/Test/MTP/MicrosoftTestingPlatformTestCommand.cs +++ b/src/Cli/dotnet/Commands/Test/MTP/MicrosoftTestingPlatformTestCommand.cs @@ -81,12 +81,25 @@ public int Run(ParseResult parseResult, bool isHelp) var output = InitializeOutput(degreeOfParallelism, parseResult, testOptions); using var ctrlC = new CtrlCCancellationManager(output.StartCancelling); + var artifactPostProcessingManager = new ArtifactPostProcessingManager(); int? exitCode = null; try { - var actionQueue = new TestApplicationActionQueue(degreeOfParallelism, buildOptions, testOptions, output, OnHelpRequested, ctrlC); + var actionQueue = new TestApplicationActionQueue( + degreeOfParallelism, + buildOptions, + testOptions, + output, + OnHelpRequested, + ctrlC, + artifactPostProcessingManager); exitCode = testHandler.RunTestApplications(actionQueue); + if (!testOptions.IsHelp && !testOptions.IsDiscovery && !ctrlC.Token.IsCancellationRequested) + { + artifactPostProcessingManager.ExecuteAsync(buildOptions, output, ctrlC).GetAwaiter().GetResult(); + } + // If all test apps exited with 0 exit code, but we detected that handshake didn't happen correctly, map that to generic failure. if (exitCode == ExitCode.Success && output.HasHandshakeFailure) { diff --git a/src/Cli/dotnet/Commands/Test/MTP/Options.cs b/src/Cli/dotnet/Commands/Test/MTP/Options.cs index ec649748ddbb..3c9425ea8639 100644 --- a/src/Cli/dotnet/Commands/Test/MTP/Options.cs +++ b/src/Cli/dotnet/Commands/Test/MTP/Options.cs @@ -18,7 +18,7 @@ internal enum TestListFormat Json, } -internal record TestOptions(bool IsHelp, bool IsDiscovery, TestListFormat ListTestsFormat); +internal record TestOptions(bool IsHelp, bool IsDiscovery, TestListFormat ListTestsFormat, bool IsArtifactPostProcessing = false); internal record PathOptions(string? ProjectOrSolutionPath, string? SolutionPath, string? TestModules, string? ResultsDirectoryPath, string? ConfigFilePath, string? DiagnosticOutputDirectoryPath); diff --git a/src/Cli/dotnet/Commands/Test/MTP/Terminal/TerminalTestReporter.cs b/src/Cli/dotnet/Commands/Test/MTP/Terminal/TerminalTestReporter.cs index b6bf97983503..64e45c311a76 100644 --- a/src/Cli/dotnet/Commands/Test/MTP/Terminal/TerminalTestReporter.cs +++ b/src/Cli/dotnet/Commands/Test/MTP/Terminal/TerminalTestReporter.cs @@ -39,6 +39,7 @@ internal sealed partial class TerminalTestReporter : IDisposable private readonly Lock _assembliesLock = new(); private readonly List _artifacts = []; + private readonly Lock _artifactsLock = new(); private readonly TerminalTestReporterOptions _options; @@ -224,7 +225,13 @@ public void TestExecutionCompleted(DateTimeOffset endTime, int? exitCode) private void AppendTestRunSummary(ITerminal terminal, int? exitCode) { - IEnumerable> artifactGroups = _artifacts.GroupBy(a => a.OutOfProcess); + TestRunArtifact[] artifacts; + lock (_artifactsLock) + { + artifacts = [.. _artifacts]; + } + + IEnumerable> artifactGroups = artifacts.GroupBy(a => a.OutOfProcess); if (artifactGroups.Any()) { @@ -1040,7 +1047,20 @@ private static void AppendLongDuration(ITerminal terminal, TimeSpan duration, bo public void Dispose() => _terminalWithProgress.Dispose(); public void ArtifactAdded(bool outOfProcess, string? assembly, string? targetFramework, string? architecture, string? executionId, string? testName, string path) - => _artifacts.Add(new TestRunArtifact(outOfProcess, assembly, targetFramework, architecture, executionId, testName, path)); + { + lock (_artifactsLock) + { + _artifacts.Add(new TestRunArtifact(outOfProcess, assembly, targetFramework, architecture, executionId, testName, path)); + } + } + + public void RemoveArtifacts(IReadOnlySet paths) + { + lock (_artifactsLock) + { + _artifacts.RemoveAll(artifact => paths.Contains(artifact.Path)); + } + } internal void WriteMessage(string text) => _terminalWithProgress.WriteToTerminal(terminal => diff --git a/src/Cli/dotnet/Commands/Test/MTP/TestApplication.cs b/src/Cli/dotnet/Commands/Test/MTP/TestApplication.cs index 95848a247919..afba396d872a 100644 --- a/src/Cli/dotnet/Commands/Test/MTP/TestApplication.cs +++ b/src/Cli/dotnet/Commands/Test/MTP/TestApplication.cs @@ -20,15 +20,24 @@ internal sealed class TestApplication( BuildOptions buildOptions, TestOptions testOptions, TerminalTestReporter output, - Action onHelpRequested) : IDisposable + Action onHelpRequested, + ArtifactPostProcessingManager? artifactPostProcessingManager = null, + ArtifactPostProcessingInvocation? artifactPostProcessingInvocation = null) : IDisposable { private static readonly Version ProtocolVersion_1_1 = new(1, 1, 0); + private static readonly TimeSpan ArtifactPostProcessingTimeout = TimeSpan.FromMinutes(15); private const int LiveOutputTailLineCount = 200; private readonly Lock _requestLock = new(); private readonly BuildOptions _buildOptions = buildOptions; private readonly Action _onHelpRequested = onHelpRequested; - private readonly TestApplicationHandler _handler = new(output, module, testOptions); + private readonly TestApplicationHandler _handler = new( + output, + module, + testOptions, + artifactPostProcessingManager, + artifactPostProcessingInvocation); + private readonly ArtifactPostProcessingInvocation? _artifactPostProcessingInvocation = artifactPostProcessingInvocation; private readonly string _pipeName = NamedPipeServer.GetPipeName(Guid.NewGuid().ToString("N")); @@ -107,7 +116,28 @@ public async Task RunAsync(CtrlCCancellationManager ctrlC) // WaitForExitAsync only waits for process exit (and doesn't wait for output) for our usage here. // If we use BeginOutputReadLine/BeginErrorReadLine, it will also wait for output which can deadlock. - await process.WaitForExitAsync(); + bool artifactPostProcessingTimedOut = false; + if (_artifactPostProcessingInvocation is null) + { + await process.WaitForExitAsync(); + } + else + { + try + { + await process.WaitForExitAsync().WaitAsync(ArtifactPostProcessingTimeout); + } + catch (TimeoutException) + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync(); + } + + artifactPostProcessingTimedOut = true; + } + } // At this point, process already exited. Allow for 5 seconds to consume stdout/stderr. // We might not be able to consume all the output if the test app has exited but left a child process alive. @@ -119,6 +149,11 @@ public async Task RunAsync(CtrlCCancellationManager ctrlC) { } + if (artifactPostProcessingTimedOut) + { + throw new TimeoutException(); + } + var exitCode = process.ExitCode; _handler.OnTestProcessExited(exitCode, stdOutBuilder.GetOutput(), stdErrBuilder.GetOutput()); @@ -177,7 +212,8 @@ private ProcessStartInfo CreateProcessStartInfo() processStartInfo.Environment[entry.Key] = entry.Value; } - if (!_buildOptions.NoLaunchProfileArguments && + if (_artifactPostProcessingInvocation is null && + !_buildOptions.NoLaunchProfileArguments && !string.IsNullOrEmpty(Module.LaunchSettings.CommandLineArgs)) { processStartInfo.Arguments = $"{processStartInfo.Arguments} {Module.LaunchSettings.CommandLineArgs}"; @@ -209,7 +245,24 @@ private string GetArguments() // RunArguments is intentionally not escaped. It can contain multiple arguments and spaces there shouldn't cause the whole // value to be wrapped in double quotes. This matches dotnet run behavior. // In short, it's expected to already be escaped properly. - StringBuilder builder = new(Module.RunProperties.Arguments); + StringBuilder builder = new( + _artifactPostProcessingInvocation is null + ? Module.RunProperties.Arguments + : GetArtifactPostProcessingLaunchArguments(Module)); + + if (_artifactPostProcessingInvocation is not null) + { + builder.Append($" {CliConstants.ArtifactPostProcessingToolName}"); + builder.Append($" {CliConstants.ArtifactPostProcessingManifestOptionKey} {ArgumentEscaper.EscapeSingleArg(_artifactPostProcessingInvocation.ManifestPath)}"); + + if (_buildOptions.PathOptions.DiagnosticOutputDirectoryPath is { } toolDiagnosticOutputDirectoryPath) + { + builder.Append($" {TestCommandDefinition.MicrosoftTestingPlatform.DiagnosticOutputDirectoryOptionName} {ArgumentEscaper.EscapeSingleArg(toolDiagnosticOutputDirectoryPath)}"); + } + + builder.Append($" {CliConstants.ServerOptionKey} {CliConstants.ServerOptionValue} {CliConstants.DotNetTestPipeOptionKey} {ArgumentEscaper.EscapeSingleArg(_pipeName)}"); + return builder.ToString(); + } if (TestOptions.IsHelp) { @@ -246,6 +299,14 @@ private string GetArguments() return builder.ToString(); } + internal static string GetArtifactPostProcessingLaunchArguments(TestModule module) + => string.Equals( + Path.GetFileNameWithoutExtension(module.RunProperties.Command), + "dotnet", + StringComparison.OrdinalIgnoreCase) + ? $"exec {ArgumentEscaper.EscapeSingleArg(module.TargetPath)}" + : string.Empty; + private async Task WaitConnectionAsync(CancellationToken token) { try diff --git a/src/Cli/dotnet/Commands/Test/MTP/TestApplicationActionQueue.cs b/src/Cli/dotnet/Commands/Test/MTP/TestApplicationActionQueue.cs index b8ebac54a05f..7cb386662f5e 100644 --- a/src/Cli/dotnet/Commands/Test/MTP/TestApplicationActionQueue.cs +++ b/src/Cli/dotnet/Commands/Test/MTP/TestApplicationActionQueue.cs @@ -17,14 +17,27 @@ internal class TestApplicationActionQueue private readonly Lock _lock = new(); - public TestApplicationActionQueue(int degreeOfParallelism, BuildOptions buildOptions, TestOptions testOptions, TerminalTestReporter output, Action onHelpRequested, CtrlCCancellationManager ctrlC) + public TestApplicationActionQueue( + int degreeOfParallelism, + BuildOptions buildOptions, + TestOptions testOptions, + TerminalTestReporter output, + Action onHelpRequested, + CtrlCCancellationManager ctrlC, + ArtifactPostProcessingManager artifactPostProcessingManager) { _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, ctrlC)); + _readers[i] = Task.Run(async () => await Read( + buildOptions, + testOptions, + output, + onHelpRequested, + ctrlC, + artifactPostProcessingManager)); } } @@ -48,7 +61,13 @@ public int CompleteEnqueueAndWait() return _aggregateExitCode ?? ExitCode.ZeroTests; } - private async Task Read(BuildOptions buildOptions, TestOptions testOptions, TerminalTestReporter output, Action onHelpRequested, CtrlCCancellationManager ctrlC) + private async Task Read( + BuildOptions buildOptions, + TestOptions testOptions, + TerminalTestReporter output, + Action onHelpRequested, + CtrlCCancellationManager ctrlC, + ArtifactPostProcessingManager artifactPostProcessingManager) { try { @@ -59,7 +78,13 @@ private async Task Read(BuildOptions buildOptions, TestOptions testOptions, Term ctrlC.Token.ThrowIfCancellationRequested(); int result = ExitCode.GenericFailure; - var testApp = new TestApplication(module, buildOptions, testOptions, output, onHelpRequested); + var testApp = new TestApplication( + module, + buildOptions, + testOptions, + output, + onHelpRequested, + artifactPostProcessingManager); try { using (testApp) diff --git a/src/Cli/dotnet/Commands/Test/MTP/TestApplicationHandler.cs b/src/Cli/dotnet/Commands/Test/MTP/TestApplicationHandler.cs index b41b46cf491b..ad35be40f565 100644 --- a/src/Cli/dotnet/Commands/Test/MTP/TestApplicationHandler.cs +++ b/src/Cli/dotnet/Commands/Test/MTP/TestApplicationHandler.cs @@ -11,17 +11,26 @@ internal sealed class TestApplicationHandler private readonly TerminalTestReporter _output; private readonly TestModule _module; private readonly TestOptions _options; + private readonly ArtifactPostProcessingManager? _artifactPostProcessingManager; + private readonly ArtifactPostProcessingInvocation? _artifactPostProcessingInvocation; private readonly Lock _lock = new(); private readonly Dictionary _testSessionEventCountPerSessionUid = new(); private (string? TargetFramework, string? Architecture, string ExecutionId)? _handshakeInfo; private bool _receivedTestHostHandshake; - public TestApplicationHandler(TerminalTestReporter output, TestModule module, TestOptions options) + public TestApplicationHandler( + TerminalTestReporter output, + TestModule module, + TestOptions options, + ArtifactPostProcessingManager? artifactPostProcessingManager = null, + ArtifactPostProcessingInvocation? artifactPostProcessingInvocation = null) { _output = output; _module = module; _options = options; + _artifactPostProcessingManager = artifactPostProcessingManager; + _artifactPostProcessingInvocation = artifactPostProcessingInvocation; } /// @@ -61,9 +70,19 @@ internal bool OnHandshakeReceived(HandshakeMessage handshakeMessage, bool gotSup var tfm = TargetFrameworkParser.GetShortTargetFramework(framework); var currentHandshakeInfo = (tfm, arch, executionId!); + if (_options.IsArtifactPostProcessing + && hostType != HandshakeMessageHostTypes.ArtifactPostProcessor) + { + ReportHandshakeFailure(string.Format( + CliCommandStrings.MismatchingHandshakeHostType, + hostType, + HandshakeMessageHostTypes.ArtifactPostProcessor)); + return false; + } + // https://github.com/microsoft/testfx/blob/2a9a353ec2bb4ce403f72e8ba1f29e01e7cf1fd4/src/Platform/Microsoft.Testing.Platform/Hosts/CommonTestHost.cs#L87-L97 string? instanceId = null; - if (hostType == "TestHost" + if (hostType == HandshakeMessageHostTypes.TestHost && !TryGetRequiredHandshakeProperty(handshakeMessage, HandshakeMessagePropertyNames.InstanceId, out instanceId, out validationError)) { ReportHandshakeFailure(validationError!); @@ -80,7 +99,7 @@ internal bool OnHandshakeReceived(HandshakeMessage handshakeMessage, bool gotSup return false; } - if (hostType == "TestHost") + if (hostType == HandshakeMessageHostTypes.TestHost) { int? attemptNumber = null; // Invalid values fall back to legacy instance-based inference. Testfx normalizes malformed @@ -121,12 +140,23 @@ internal bool OnHandshakeReceived(HandshakeMessage handshakeMessage, bool gotSup return false; } + if (!_options.IsArtifactPostProcessing) + { + _artifactPostProcessingManager?.RecordCapabilities( + _module, + _module.TargetFramework ?? tfm, + arch, + handshakeMessage); + } + return true; } private bool IsExpectedExecutionMode(string reportedMode, out string expectedMode) { - expectedMode = _options.IsHelp + expectedMode = _options.IsArtifactPostProcessing + ? HandshakeMessageExecutionModes.Tool + : _options.IsHelp ? HandshakeMessageExecutionModes.Help : _options.IsDiscovery ? HandshakeMessageExecutionModes.Discover @@ -147,7 +177,14 @@ private bool IsExpectedExecutionMode(string reportedMode, out string expectedMod // HandshakeFailure with no actionable context. Explicit programmatic rejections here (unsupported // protocol version, missing required property, mismatching handshake info, mismatching execution // mode) are real protocol failures and must still be surfaced even when the SDK is in help mode. - private void ReportHandshakeFailure(string failureMessage) => + private void ReportHandshakeFailure(string failureMessage) + { + if (_artifactPostProcessingInvocation is not null) + { + _artifactPostProcessingInvocation.RecordFailure(failureMessage); + return; + } + _output.HandshakeFailure( _module.TargetPath, string.Empty, @@ -155,6 +192,7 @@ private void ReportHandshakeFailure(string failureMessage) => failureMessage, string.Empty, reportEvenWhenHelp: true); + } private static bool TryGetRequiredHandshakeProperty(HandshakeMessage handshakeMessage, byte propertyId, out string? value, out string? failureMessage) { @@ -199,6 +237,8 @@ private static string GetHandshakePropertyName(byte propertyId) => HandshakeMessagePropertyNames.IsIDE => nameof(HandshakeMessagePropertyNames.IsIDE), HandshakeMessagePropertyNames.ExecutionMode => nameof(HandshakeMessagePropertyNames.ExecutionMode), HandshakeMessagePropertyNames.AttemptNumber => nameof(HandshakeMessagePropertyNames.AttemptNumber), + HandshakeMessagePropertyNames.SupportedPostProcessorKinds => nameof(HandshakeMessagePropertyNames.SupportedPostProcessorKinds), + HandshakeMessagePropertyNames.SupportedPostProcessorExtensionsLegacy => nameof(HandshakeMessagePropertyNames.SupportedPostProcessorExtensionsLegacy), _ => string.Empty, }; @@ -367,7 +407,9 @@ internal void OnFileArtifactsReceived(FileArtifactMessages fileArtifactMessages) throw new InvalidOperationException(string.Format(CliCommandStrings.UnexpectedMessageWithoutHandshake, nameof(FileArtifactMessages))); } - if (!_receivedTestHostHandshake) + if (_options.IsArtifactPostProcessing + ? _artifactPostProcessingInvocation is null + : !_receivedTestHostHandshake) { throw new InvalidOperationException(string.Format(CliCommandStrings.UnexpectedMessageWithoutTestHostHandshake, nameof(FileArtifactMessages))); } @@ -391,10 +433,28 @@ internal void OnFileArtifactsReceived(FileArtifactMessages fileArtifactMessages) nameof(FileArtifactMessage.FullPath), nameof(FileArtifactMessage)); - _output.ArtifactAdded( - outOfProcess: false, - _module.TargetPath, handshakeInfo.TargetFramework, handshakeInfo.Architecture, handshakeInfo.ExecutionId, - artifact.TestDisplayName, fullPath); + if (_artifactPostProcessingInvocation is not null) + { + _artifactPostProcessingInvocation.RecordOutput( + _module, + handshakeInfo.TargetFramework, + handshakeInfo.Architecture, + handshakeInfo.ExecutionId, + artifact with { FullPath = fullPath }); + } + else + { + _artifactPostProcessingManager?.RecordArtifact( + _module, + _module.TargetFramework ?? handshakeInfo.TargetFramework, + handshakeInfo.Architecture, + handshakeInfo.ExecutionId, + artifact with { FullPath = fullPath }); + _output.ArtifactAdded( + outOfProcess: false, + _module.TargetPath, handshakeInfo.TargetFramework, handshakeInfo.Architecture, handshakeInfo.ExecutionId, + artifact.TestDisplayName, fullPath); + } } } @@ -550,6 +610,14 @@ internal void WriteMessage(string? text) internal void OnTestProcessExited(int exitCode, string outputData, string errorData) { + if (_options.IsArtifactPostProcessing) + { + WriteMessage(outputData); + WriteMessage(errorData); + LogTestProcessExit(exitCode, outputData, errorData); + return; + } + if (_receivedTestHostHandshake && _handshakeInfo.HasValue) { // If we received a handshake from TestHostController but not from TestHost, diff --git a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.cs.xlf b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.cs.xlf index b1e58cab23ce..d68a523b3fad 100644 --- a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.cs.xlf +++ b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.cs.xlf @@ -77,6 +77,16 @@ Při instalaci balíčku nástroje .NET povolte downgrade balíčku. + + Artifact post-processing with '{0}' failed: {1} Original artifacts will be reported. + Artifact post-processing with '{0}' failed: {1} Original artifacts will be reported. + {0} is the test application path. {1} is the failure reason. + + + Artifact post-processing with '{0}' exited with code {1}. Any artifacts that were not merged will be reported individually. + Artifact post-processing with '{0}' exited with code {1}. Any artifacts that were not merged will be reported individually. + {0} is the test application path. {1} is the process exit code. + The Aspire workload is deprecated and no longer necessary. Aspire is now available as NuGet packages that you can add directly to your projects. For more information, see https://aka.ms/aspire/support-policy Úloha Aspire je zastaralá a už není potřeba. Aspire je nyní k dispozici v podobě balíčků NuGet, které můžete přidat přímo do svých projektů. Další informace najdete na https://aka.ms/aspire/support-policy @@ -147,6 +157,11 @@ The test host reported execution mode '{0}', but 'dotnet test' expected '{1}'. This typically happens when an option such as '--help', '-?' or '--list-tests' was injected into the test host through a non-CLI channel (e.g. the 'TestingPlatformCommandLineArguments' or 'RunArguments' MSBuild properties, or 'launchSettings.json' commandLineArgs). Pass these options directly to 'dotnet test' instead. {Locked="dotnet test"}{Locked="--help"}{Locked="-?"}{Locked="--list-tests"}{Locked="TestingPlatformCommandLineArguments"}{Locked="RunArguments"}{Locked="launchSettings.json"}{Locked="commandLineArgs"} + + The test host reported host type '{0}', but 'dotnet test' expected '{1}'. + The test host reported host type '{0}', but 'dotnet test' expected '{1}'. + {Locked="dotnet test"} + Do not display the startup banner or the copyright message. Nezobrazovat úvodní nápis ani zprávu o autorských právech diff --git a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.de.xlf b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.de.xlf index 7957c660dd14..03f6b713c536 100644 --- a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.de.xlf +++ b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.de.xlf @@ -77,6 +77,16 @@ Paketdowngrade beim Installieren eines .NET-Toolpakets zulassen. + + Artifact post-processing with '{0}' failed: {1} Original artifacts will be reported. + Artifact post-processing with '{0}' failed: {1} Original artifacts will be reported. + {0} is the test application path. {1} is the failure reason. + + + Artifact post-processing with '{0}' exited with code {1}. Any artifacts that were not merged will be reported individually. + Artifact post-processing with '{0}' exited with code {1}. Any artifacts that were not merged will be reported individually. + {0} is the test application path. {1} is the process exit code. + The Aspire workload is deprecated and no longer necessary. Aspire is now available as NuGet packages that you can add directly to your projects. For more information, see https://aka.ms/aspire/support-policy Die Workload „Aspire“ ist veraltet und nicht mehr erforderlich. Aspire ist jetzt als NuGet-Pakete verfügbar, die Sie direkt Ihren Projekten hinzufügen können. Weitere Informationen finden Sie unter https://aka.ms/aspire/support-policy @@ -147,6 +157,11 @@ The test host reported execution mode '{0}', but 'dotnet test' expected '{1}'. This typically happens when an option such as '--help', '-?' or '--list-tests' was injected into the test host through a non-CLI channel (e.g. the 'TestingPlatformCommandLineArguments' or 'RunArguments' MSBuild properties, or 'launchSettings.json' commandLineArgs). Pass these options directly to 'dotnet test' instead. {Locked="dotnet test"}{Locked="--help"}{Locked="-?"}{Locked="--list-tests"}{Locked="TestingPlatformCommandLineArguments"}{Locked="RunArguments"}{Locked="launchSettings.json"}{Locked="commandLineArgs"} + + The test host reported host type '{0}', but 'dotnet test' expected '{1}'. + The test host reported host type '{0}', but 'dotnet test' expected '{1}'. + {Locked="dotnet test"} + Do not display the startup banner or the copyright message. Zeigt kein Startbanner und keine Copyrightmeldung an. diff --git a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.es.xlf b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.es.xlf index 380aaa3a0754..f069245e21e0 100644 --- a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.es.xlf +++ b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.es.xlf @@ -77,6 +77,16 @@ Permitir la degradación del paquete al instalar un paquete de herramientas de .NET. + + Artifact post-processing with '{0}' failed: {1} Original artifacts will be reported. + Artifact post-processing with '{0}' failed: {1} Original artifacts will be reported. + {0} is the test application path. {1} is the failure reason. + + + Artifact post-processing with '{0}' exited with code {1}. Any artifacts that were not merged will be reported individually. + Artifact post-processing with '{0}' exited with code {1}. Any artifacts that were not merged will be reported individually. + {0} is the test application path. {1} is the process exit code. + The Aspire workload is deprecated and no longer necessary. Aspire is now available as NuGet packages that you can add directly to your projects. For more information, see https://aka.ms/aspire/support-policy La carga de trabajo Aspire está en desuso y ya no es necesaria. Ahora Aspire está disponible como paquetes NuGet que puedes añadir directamente a tus proyectos. Para obtener más información, consulta https://aka.ms/aspire/support-policy @@ -147,6 +157,11 @@ The test host reported execution mode '{0}', but 'dotnet test' expected '{1}'. This typically happens when an option such as '--help', '-?' or '--list-tests' was injected into the test host through a non-CLI channel (e.g. the 'TestingPlatformCommandLineArguments' or 'RunArguments' MSBuild properties, or 'launchSettings.json' commandLineArgs). Pass these options directly to 'dotnet test' instead. {Locked="dotnet test"}{Locked="--help"}{Locked="-?"}{Locked="--list-tests"}{Locked="TestingPlatformCommandLineArguments"}{Locked="RunArguments"}{Locked="launchSettings.json"}{Locked="commandLineArgs"} + + The test host reported host type '{0}', but 'dotnet test' expected '{1}'. + The test host reported host type '{0}', but 'dotnet test' expected '{1}'. + {Locked="dotnet test"} + Do not display the startup banner or the copyright message. No mostrar la pancarta de inicio ni el mensaje de copyright. diff --git a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.fr.xlf b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.fr.xlf index 682ef17c0211..5ad04dc966a1 100644 --- a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.fr.xlf +++ b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.fr.xlf @@ -77,6 +77,16 @@ Autoriser le passage à une version antérieure du package lors de l’installation d’un package d’outils .NET. + + Artifact post-processing with '{0}' failed: {1} Original artifacts will be reported. + Artifact post-processing with '{0}' failed: {1} Original artifacts will be reported. + {0} is the test application path. {1} is the failure reason. + + + Artifact post-processing with '{0}' exited with code {1}. Any artifacts that were not merged will be reported individually. + Artifact post-processing with '{0}' exited with code {1}. Any artifacts that were not merged will be reported individually. + {0} is the test application path. {1} is the process exit code. + The Aspire workload is deprecated and no longer necessary. Aspire is now available as NuGet packages that you can add directly to your projects. For more information, see https://aka.ms/aspire/support-policy La charge de travail Aspire est dépréciée et n’est plus nécessaire. Aspire est désormais disponible sous forme de packages NuGet que vous pouvez ajouter directement à vos projets. Pour plus d’informations, consultez https://aka.ms/aspire/support-policy @@ -147,6 +157,11 @@ The test host reported execution mode '{0}', but 'dotnet test' expected '{1}'. This typically happens when an option such as '--help', '-?' or '--list-tests' was injected into the test host through a non-CLI channel (e.g. the 'TestingPlatformCommandLineArguments' or 'RunArguments' MSBuild properties, or 'launchSettings.json' commandLineArgs). Pass these options directly to 'dotnet test' instead. {Locked="dotnet test"}{Locked="--help"}{Locked="-?"}{Locked="--list-tests"}{Locked="TestingPlatformCommandLineArguments"}{Locked="RunArguments"}{Locked="launchSettings.json"}{Locked="commandLineArgs"} + + The test host reported host type '{0}', but 'dotnet test' expected '{1}'. + The test host reported host type '{0}', but 'dotnet test' expected '{1}'. + {Locked="dotnet test"} + Do not display the startup banner or the copyright message. N'affiche pas la bannière de démarrage ni le message de copyright. diff --git a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.it.xlf b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.it.xlf index 5fe2071c60c5..d90f804ec634 100644 --- a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.it.xlf +++ b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.it.xlf @@ -77,6 +77,16 @@ Consente il downgrade del pacchetto durante l'installazione di un pacchetto di strumenti .NET. + + Artifact post-processing with '{0}' failed: {1} Original artifacts will be reported. + Artifact post-processing with '{0}' failed: {1} Original artifacts will be reported. + {0} is the test application path. {1} is the failure reason. + + + Artifact post-processing with '{0}' exited with code {1}. Any artifacts that were not merged will be reported individually. + Artifact post-processing with '{0}' exited with code {1}. Any artifacts that were not merged will be reported individually. + {0} is the test application path. {1} is the process exit code. + The Aspire workload is deprecated and no longer necessary. Aspire is now available as NuGet packages that you can add directly to your projects. For more information, see https://aka.ms/aspire/support-policy Il carico di lavoro Aspire è deprecato e non è più necessario. Aspire ora è disponibile come pacchetti NuGet che puoi aggiungere direttamente ai tuoi progetti. Per altre informazioni, visita https://aka.ms/aspire/support-policy @@ -147,6 +157,11 @@ The test host reported execution mode '{0}', but 'dotnet test' expected '{1}'. This typically happens when an option such as '--help', '-?' or '--list-tests' was injected into the test host through a non-CLI channel (e.g. the 'TestingPlatformCommandLineArguments' or 'RunArguments' MSBuild properties, or 'launchSettings.json' commandLineArgs). Pass these options directly to 'dotnet test' instead. {Locked="dotnet test"}{Locked="--help"}{Locked="-?"}{Locked="--list-tests"}{Locked="TestingPlatformCommandLineArguments"}{Locked="RunArguments"}{Locked="launchSettings.json"}{Locked="commandLineArgs"} + + The test host reported host type '{0}', but 'dotnet test' expected '{1}'. + The test host reported host type '{0}', but 'dotnet test' expected '{1}'. + {Locked="dotnet test"} + Do not display the startup banner or the copyright message. Evita la visualizzazione del messaggio di avvio o di copyright. diff --git a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.ja.xlf b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.ja.xlf index c13e31663d28..c9ee12ca6562 100644 --- a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.ja.xlf +++ b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.ja.xlf @@ -77,6 +77,16 @@ .NET ツール パッケージのインストール時にパッケージのダウングレードを許可します。 + + Artifact post-processing with '{0}' failed: {1} Original artifacts will be reported. + Artifact post-processing with '{0}' failed: {1} Original artifacts will be reported. + {0} is the test application path. {1} is the failure reason. + + + Artifact post-processing with '{0}' exited with code {1}. Any artifacts that were not merged will be reported individually. + Artifact post-processing with '{0}' exited with code {1}. Any artifacts that were not merged will be reported individually. + {0} is the test application path. {1} is the process exit code. + The Aspire workload is deprecated and no longer necessary. Aspire is now available as NuGet packages that you can add directly to your projects. For more information, see https://aka.ms/aspire/support-policy Aspire ワークロードは非推奨となり、不要になりました。Aspire は現在、プロジェクトに直接追加できる NuGet パッケージとして利用可能です。詳細情報については、https://aka.ms/aspire/support-policy をご覧ください @@ -147,6 +157,11 @@ The test host reported execution mode '{0}', but 'dotnet test' expected '{1}'. This typically happens when an option such as '--help', '-?' or '--list-tests' was injected into the test host through a non-CLI channel (e.g. the 'TestingPlatformCommandLineArguments' or 'RunArguments' MSBuild properties, or 'launchSettings.json' commandLineArgs). Pass these options directly to 'dotnet test' instead. {Locked="dotnet test"}{Locked="--help"}{Locked="-?"}{Locked="--list-tests"}{Locked="TestingPlatformCommandLineArguments"}{Locked="RunArguments"}{Locked="launchSettings.json"}{Locked="commandLineArgs"} + + The test host reported host type '{0}', but 'dotnet test' expected '{1}'. + The test host reported host type '{0}', but 'dotnet test' expected '{1}'. + {Locked="dotnet test"} + Do not display the startup banner or the copyright message. 著作権情報を表示しません。 diff --git a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.ko.xlf b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.ko.xlf index 4cb39d88ed6e..e8ee1ea33595 100644 --- a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.ko.xlf +++ b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.ko.xlf @@ -77,6 +77,16 @@ .NET 도구 패키지를 설치할 때 패키지 다운그레이드를 허용합니다. + + Artifact post-processing with '{0}' failed: {1} Original artifacts will be reported. + Artifact post-processing with '{0}' failed: {1} Original artifacts will be reported. + {0} is the test application path. {1} is the failure reason. + + + Artifact post-processing with '{0}' exited with code {1}. Any artifacts that were not merged will be reported individually. + Artifact post-processing with '{0}' exited with code {1}. Any artifacts that were not merged will be reported individually. + {0} is the test application path. {1} is the process exit code. + The Aspire workload is deprecated and no longer necessary. Aspire is now available as NuGet packages that you can add directly to your projects. For more information, see https://aka.ms/aspire/support-policy Aspire 워크로드는 더 이상 사용되지 않으며 필요하지 않습니다. Aspire는 이제 NuGet 패키지로 제공되어 프로젝트에 직접 추가할 수 있습니다. 자세한 내용은 https://aka.ms/aspire/support-policy를 참조하세요. @@ -147,6 +157,11 @@ The test host reported execution mode '{0}', but 'dotnet test' expected '{1}'. This typically happens when an option such as '--help', '-?' or '--list-tests' was injected into the test host through a non-CLI channel (e.g. the 'TestingPlatformCommandLineArguments' or 'RunArguments' MSBuild properties, or 'launchSettings.json' commandLineArgs). Pass these options directly to 'dotnet test' instead. {Locked="dotnet test"}{Locked="--help"}{Locked="-?"}{Locked="--list-tests"}{Locked="TestingPlatformCommandLineArguments"}{Locked="RunArguments"}{Locked="launchSettings.json"}{Locked="commandLineArgs"} + + The test host reported host type '{0}', but 'dotnet test' expected '{1}'. + The test host reported host type '{0}', but 'dotnet test' expected '{1}'. + {Locked="dotnet test"} + Do not display the startup banner or the copyright message. 시작 배너 또는 저작권 메시지를 표시하지 않습니다. diff --git a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.pl.xlf b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.pl.xlf index 8815b7f2bce4..dcf0c966444a 100644 --- a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.pl.xlf +++ b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.pl.xlf @@ -77,6 +77,16 @@ Zezwalaj na obniżanie wersji pakietu podczas instalowania pakietu narzędzi .NET. + + Artifact post-processing with '{0}' failed: {1} Original artifacts will be reported. + Artifact post-processing with '{0}' failed: {1} Original artifacts will be reported. + {0} is the test application path. {1} is the failure reason. + + + Artifact post-processing with '{0}' exited with code {1}. Any artifacts that were not merged will be reported individually. + Artifact post-processing with '{0}' exited with code {1}. Any artifacts that were not merged will be reported individually. + {0} is the test application path. {1} is the process exit code. + The Aspire workload is deprecated and no longer necessary. Aspire is now available as NuGet packages that you can add directly to your projects. For more information, see https://aka.ms/aspire/support-policy Obciążenie Aspire jest przestarzałe i nie jest już konieczne. Usługa Aspire jest teraz dostępna jako pakiety NuGet, które można dodawać bezpośrednio do projektów. Więcej informacji znajdziesz na stronie https://aka.ms/aspire/support-policy @@ -147,6 +157,11 @@ The test host reported execution mode '{0}', but 'dotnet test' expected '{1}'. This typically happens when an option such as '--help', '-?' or '--list-tests' was injected into the test host through a non-CLI channel (e.g. the 'TestingPlatformCommandLineArguments' or 'RunArguments' MSBuild properties, or 'launchSettings.json' commandLineArgs). Pass these options directly to 'dotnet test' instead. {Locked="dotnet test"}{Locked="--help"}{Locked="-?"}{Locked="--list-tests"}{Locked="TestingPlatformCommandLineArguments"}{Locked="RunArguments"}{Locked="launchSettings.json"}{Locked="commandLineArgs"} + + The test host reported host type '{0}', but 'dotnet test' expected '{1}'. + The test host reported host type '{0}', but 'dotnet test' expected '{1}'. + {Locked="dotnet test"} + Do not display the startup banner or the copyright message. Nie wyświetlaj baneru początkowego ani komunikatu o prawach autorskich. diff --git a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.pt-BR.xlf b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.pt-BR.xlf index f7b819fdc97e..3abd4cc1b7fa 100644 --- a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.pt-BR.xlf +++ b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.pt-BR.xlf @@ -77,6 +77,16 @@ Permitir downgrade de pacote ao instalar um pacote de ferramentas do .NET. + + Artifact post-processing with '{0}' failed: {1} Original artifacts will be reported. + Artifact post-processing with '{0}' failed: {1} Original artifacts will be reported. + {0} is the test application path. {1} is the failure reason. + + + Artifact post-processing with '{0}' exited with code {1}. Any artifacts that were not merged will be reported individually. + Artifact post-processing with '{0}' exited with code {1}. Any artifacts that were not merged will be reported individually. + {0} is the test application path. {1} is the process exit code. + The Aspire workload is deprecated and no longer necessary. Aspire is now available as NuGet packages that you can add directly to your projects. For more information, see https://aka.ms/aspire/support-policy A carga de trabalho Aspire está obsoleta e não é mais necessária. O Aspire agora está disponível como pacotes NuGet que você pode adicionar diretamente aos seus projetos. Para mais informações, consulte https://aka.ms/aspire/support-policy @@ -147,6 +157,11 @@ The test host reported execution mode '{0}', but 'dotnet test' expected '{1}'. This typically happens when an option such as '--help', '-?' or '--list-tests' was injected into the test host through a non-CLI channel (e.g. the 'TestingPlatformCommandLineArguments' or 'RunArguments' MSBuild properties, or 'launchSettings.json' commandLineArgs). Pass these options directly to 'dotnet test' instead. {Locked="dotnet test"}{Locked="--help"}{Locked="-?"}{Locked="--list-tests"}{Locked="TestingPlatformCommandLineArguments"}{Locked="RunArguments"}{Locked="launchSettings.json"}{Locked="commandLineArgs"} + + The test host reported host type '{0}', but 'dotnet test' expected '{1}'. + The test host reported host type '{0}', but 'dotnet test' expected '{1}'. + {Locked="dotnet test"} + Do not display the startup banner or the copyright message. Não exibe a faixa de inicialização ou a mensagem de direitos autorais. diff --git a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.ru.xlf b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.ru.xlf index 3deeb5bc49fc..51f12fe5c977 100644 --- a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.ru.xlf +++ b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.ru.xlf @@ -77,6 +77,16 @@ Разрешить переход на использование более ранней версии пакета при установке пакета инструментов .NET. + + Artifact post-processing with '{0}' failed: {1} Original artifacts will be reported. + Artifact post-processing with '{0}' failed: {1} Original artifacts will be reported. + {0} is the test application path. {1} is the failure reason. + + + Artifact post-processing with '{0}' exited with code {1}. Any artifacts that were not merged will be reported individually. + Artifact post-processing with '{0}' exited with code {1}. Any artifacts that were not merged will be reported individually. + {0} is the test application path. {1} is the process exit code. + The Aspire workload is deprecated and no longer necessary. Aspire is now available as NuGet packages that you can add directly to your projects. For more information, see https://aka.ms/aspire/support-policy Рабочая нагрузка Aspire является нерекомендуемой и больше не требуется. Теперь нагрузка Aspire доступна в виде пакетов NuGet, которые можно добавлять непосредственно в проекты. Дополнительные сведения см. на странице https://aka.ms/aspire/support-policy @@ -147,6 +157,11 @@ The test host reported execution mode '{0}', but 'dotnet test' expected '{1}'. This typically happens when an option such as '--help', '-?' or '--list-tests' was injected into the test host through a non-CLI channel (e.g. the 'TestingPlatformCommandLineArguments' or 'RunArguments' MSBuild properties, or 'launchSettings.json' commandLineArgs). Pass these options directly to 'dotnet test' instead. {Locked="dotnet test"}{Locked="--help"}{Locked="-?"}{Locked="--list-tests"}{Locked="TestingPlatformCommandLineArguments"}{Locked="RunArguments"}{Locked="launchSettings.json"}{Locked="commandLineArgs"} + + The test host reported host type '{0}', but 'dotnet test' expected '{1}'. + The test host reported host type '{0}', but 'dotnet test' expected '{1}'. + {Locked="dotnet test"} + Do not display the startup banner or the copyright message. Не отображать начальный баннер или сообщение об авторских правах. diff --git a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.tr.xlf b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.tr.xlf index 05effa1a2e5d..edd13bf786fc 100644 --- a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.tr.xlf +++ b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.tr.xlf @@ -77,6 +77,16 @@ Bir .NET araç paketini yüklerken paketi eski sürüme düşürmeye izin verin. + + Artifact post-processing with '{0}' failed: {1} Original artifacts will be reported. + Artifact post-processing with '{0}' failed: {1} Original artifacts will be reported. + {0} is the test application path. {1} is the failure reason. + + + Artifact post-processing with '{0}' exited with code {1}. Any artifacts that were not merged will be reported individually. + Artifact post-processing with '{0}' exited with code {1}. Any artifacts that were not merged will be reported individually. + {0} is the test application path. {1} is the process exit code. + The Aspire workload is deprecated and no longer necessary. Aspire is now available as NuGet packages that you can add directly to your projects. For more information, see https://aka.ms/aspire/support-policy Aspire iş yükü kullanım dışı bırakıldı ve artık gerekli değil. Aspire, projelerinize doğrudan ekleyebileceğiniz NuGet paketleri olarak kullanılabilir. Daha fazla bilgi için bkz. https://aka.ms/aspire/support-policy @@ -147,6 +157,11 @@ The test host reported execution mode '{0}', but 'dotnet test' expected '{1}'. This typically happens when an option such as '--help', '-?' or '--list-tests' was injected into the test host through a non-CLI channel (e.g. the 'TestingPlatformCommandLineArguments' or 'RunArguments' MSBuild properties, or 'launchSettings.json' commandLineArgs). Pass these options directly to 'dotnet test' instead. {Locked="dotnet test"}{Locked="--help"}{Locked="-?"}{Locked="--list-tests"}{Locked="TestingPlatformCommandLineArguments"}{Locked="RunArguments"}{Locked="launchSettings.json"}{Locked="commandLineArgs"} + + The test host reported host type '{0}', but 'dotnet test' expected '{1}'. + The test host reported host type '{0}', but 'dotnet test' expected '{1}'. + {Locked="dotnet test"} + Do not display the startup banner or the copyright message. Başlangıç bandını veya telif hakkı iletisini görüntüleme. diff --git a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.zh-Hans.xlf b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.zh-Hans.xlf index 187c0fd21578..24b405e04627 100644 --- a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.zh-Hans.xlf +++ b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.zh-Hans.xlf @@ -77,6 +77,16 @@ 安装 .NET 工具包时允许包降级。 + + Artifact post-processing with '{0}' failed: {1} Original artifacts will be reported. + Artifact post-processing with '{0}' failed: {1} Original artifacts will be reported. + {0} is the test application path. {1} is the failure reason. + + + Artifact post-processing with '{0}' exited with code {1}. Any artifacts that were not merged will be reported individually. + Artifact post-processing with '{0}' exited with code {1}. Any artifacts that were not merged will be reported individually. + {0} is the test application path. {1} is the process exit code. + The Aspire workload is deprecated and no longer necessary. Aspire is now available as NuGet packages that you can add directly to your projects. For more information, see https://aka.ms/aspire/support-policy Aspire 工作负载已弃用,并且不再是必需项。Aspire 现在以 NuGet 包的形式提供,可以直接将其添加到项目中。有关详细信息,请参阅 https://aka.ms/aspire/support-policy @@ -147,6 +157,11 @@ The test host reported execution mode '{0}', but 'dotnet test' expected '{1}'. This typically happens when an option such as '--help', '-?' or '--list-tests' was injected into the test host through a non-CLI channel (e.g. the 'TestingPlatformCommandLineArguments' or 'RunArguments' MSBuild properties, or 'launchSettings.json' commandLineArgs). Pass these options directly to 'dotnet test' instead. {Locked="dotnet test"}{Locked="--help"}{Locked="-?"}{Locked="--list-tests"}{Locked="TestingPlatformCommandLineArguments"}{Locked="RunArguments"}{Locked="launchSettings.json"}{Locked="commandLineArgs"} + + The test host reported host type '{0}', but 'dotnet test' expected '{1}'. + The test host reported host type '{0}', but 'dotnet test' expected '{1}'. + {Locked="dotnet test"} + Do not display the startup banner or the copyright message. 不显示启动版权标志或版权消息。 diff --git a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.zh-Hant.xlf b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.zh-Hant.xlf index 939e7e12d702..802dc1ccaf9c 100644 --- a/src/Cli/dotnet/Commands/xlf/CliCommandStrings.zh-Hant.xlf +++ b/src/Cli/dotnet/Commands/xlf/CliCommandStrings.zh-Hant.xlf @@ -77,6 +77,16 @@ 安裝 .NET 工具套件時允許套件降級。 + + Artifact post-processing with '{0}' failed: {1} Original artifacts will be reported. + Artifact post-processing with '{0}' failed: {1} Original artifacts will be reported. + {0} is the test application path. {1} is the failure reason. + + + Artifact post-processing with '{0}' exited with code {1}. Any artifacts that were not merged will be reported individually. + Artifact post-processing with '{0}' exited with code {1}. Any artifacts that were not merged will be reported individually. + {0} is the test application path. {1} is the process exit code. + The Aspire workload is deprecated and no longer necessary. Aspire is now available as NuGet packages that you can add directly to your projects. For more information, see https://aka.ms/aspire/support-policy Aspire 工作負載已棄用,不再需要。Aspire 現已作為 NuGet 套件提供,您可以將它直接新增至專案。如需詳細資訊,請參閱 https://aka.ms/aspire/support-policy @@ -147,6 +157,11 @@ The test host reported execution mode '{0}', but 'dotnet test' expected '{1}'. This typically happens when an option such as '--help', '-?' or '--list-tests' was injected into the test host through a non-CLI channel (e.g. the 'TestingPlatformCommandLineArguments' or 'RunArguments' MSBuild properties, or 'launchSettings.json' commandLineArgs). Pass these options directly to 'dotnet test' instead. {Locked="dotnet test"}{Locked="--help"}{Locked="-?"}{Locked="--list-tests"}{Locked="TestingPlatformCommandLineArguments"}{Locked="RunArguments"}{Locked="launchSettings.json"}{Locked="commandLineArgs"} + + The test host reported host type '{0}', but 'dotnet test' expected '{1}'. + The test host reported host type '{0}', but 'dotnet test' expected '{1}'. + {Locked="dotnet test"} + Do not display the startup banner or the copyright message. 不顯示啟始資訊或著作權訊息。 diff --git a/test/dotnet.Tests/CommandTests/Test/ArtifactPostProcessingManagerTests.cs b/test/dotnet.Tests/CommandTests/Test/ArtifactPostProcessingManagerTests.cs new file mode 100644 index 000000000000..1de00153a755 --- /dev/null +++ b/test/dotnet.Tests/CommandTests/Test/ArtifactPostProcessingManagerTests.cs @@ -0,0 +1,169 @@ +// 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; +using Microsoft.DotNet.Cli.Commands.Test.Terminal; +using TestExitCode = Microsoft.DotNet.Cli.Commands.Test.ExitCode; + +namespace dotnet.Tests.CommandTests.Test; + +[TestClass] +public class ArtifactPostProcessingManagerTests +{ + [TestMethod] + public void ApplyOutputs_MatchingKind_ReplacesOriginalArtifacts() + { + var console = new CapturingConsole(); + using var reporter = CreateReporter(console); + ArtifactPostProcessingArtifact first = CreateArtifact("first.trx", "microsoft.testing.trx"); + ArtifactPostProcessingArtifact second = CreateArtifact("second.trx", "microsoft.testing.trx"); + ArtifactPostProcessingApplication application = CreateApplication(); + var group = new ArtifactPostProcessingGroup( + "microsoft.testing.trx", + IsKind: true, + [first, second], + [application]); + var job = new ArtifactPostProcessingJob(application, [group]); + reporter.ArtifactAdded(false, "A.dll", "net10.0", "x64", "execution-1", null, first.Path); + reporter.ArtifactAdded(false, "B.dll", "net10.0", "x64", "execution-2", null, second.Path); + ArtifactPostProcessingArtifact merged = CreateArtifact("merged.trx", "microsoft.testing.trx"); + + ArtifactPostProcessingManager.ApplyOutputs(reporter, job, [merged]); + reporter.TestExecutionCompleted(DateTimeOffset.UtcNow, TestExitCode.Success); + + string output = console.GetOutput(); + output.Should().Contain("merged.trx"); + output.Should().NotContain("first.trx"); + output.Should().NotContain("second.trx"); + } + + [TestMethod] + public void ApplyOutputs_UnmatchedOutput_StillReportsOutputAndPreservesOriginals() + { + var console = new CapturingConsole(); + using var reporter = CreateReporter(console); + ArtifactPostProcessingArtifact first = CreateArtifact("first.coverage", "microsoft.codecoverage"); + ArtifactPostProcessingArtifact second = CreateArtifact("second.coverage", "microsoft.codecoverage"); + ArtifactPostProcessingApplication application = CreateApplication(); + var group = new ArtifactPostProcessingGroup( + "microsoft.codecoverage", + IsKind: true, + [first, second], + [application]); + var job = new ArtifactPostProcessingJob(application, [group]); + reporter.ArtifactAdded(false, "A.dll", "net10.0", "x64", "execution-1", null, first.Path); + reporter.ArtifactAdded(false, "B.dll", "net10.0", "x64", "execution-2", null, second.Path); + ArtifactPostProcessingArtifact converted = CreateArtifact("coverage.cobertura.xml", "cobertura"); + + ArtifactPostProcessingManager.ApplyOutputs(reporter, job, [converted]); + reporter.TestExecutionCompleted(DateTimeOffset.UtcNow, TestExitCode.Success); + + string output = console.GetOutput(); + output.Should().Contain("coverage.cobertura.xml"); + output.Should().Contain("first.coverage"); + output.Should().Contain("second.coverage"); + } + + [TestMethod] + public void ApplyOutputs_KindOutput_AlsoConsumesLegacyInputsWithSameExtension() + { + var console = new CapturingConsole(); + using var reporter = CreateReporter(console); + ArtifactPostProcessingArtifact taggedFirst = CreateArtifact("tagged-first.xml", "example.junit"); + ArtifactPostProcessingArtifact taggedSecond = CreateArtifact("tagged-second.xml", "example.junit"); + ArtifactPostProcessingArtifact legacyFirst = CreateArtifact("legacy-first.xml", kind: null); + ArtifactPostProcessingArtifact legacySecond = CreateArtifact("legacy-second.xml", kind: null); + ArtifactPostProcessingApplication application = CreateApplication(); + var taggedGroup = new ArtifactPostProcessingGroup( + "example.junit", + IsKind: true, + [taggedFirst, taggedSecond], + [application]); + var fallbackGroup = new ArtifactPostProcessingGroup( + ".xml", + IsKind: false, + [legacyFirst, legacySecond], + [application]); + var job = new ArtifactPostProcessingJob(application, [taggedGroup, fallbackGroup]); + foreach (ArtifactPostProcessingArtifact artifact in taggedGroup.Artifacts.Concat(fallbackGroup.Artifacts)) + { + reporter.ArtifactAdded(false, "A.dll", "net10.0", "x64", artifact.ExecutionId, null, artifact.Path); + } + + ArtifactPostProcessingManager.ApplyOutputs( + reporter, + job, + [CreateArtifact("merged.xml", "example.junit")]); + reporter.TestExecutionCompleted(DateTimeOffset.UtcNow, TestExitCode.Success); + + string output = console.GetOutput(); + output.Should().Contain("merged.xml"); + output.Should().NotContain("tagged-first.xml"); + output.Should().NotContain("tagged-second.xml"); + output.Should().NotContain("legacy-first.xml"); + output.Should().NotContain("legacy-second.xml"); + } + + [TestMethod] + public void GetArtifactPostProcessingLaunchArguments_DotnetCommand_UsesOnlyExecAndTargetPath() + { + ArtifactPostProcessingApplication application = CreateApplication(); + + string arguments = TestApplication.GetArtifactPostProcessingLaunchArguments(application.Module); + + arguments.Should().Be("exec A.dll"); + } + + [TestMethod] + public void GetArtifactPostProcessingLaunchArguments_AppHost_UsesNoTestArguments() + { + ArtifactPostProcessingApplication application = CreateApplication(); + TestModule appHostModule = application.Module with + { + RunProperties = new RunProperties("A.exe", "--filter injected", null), + }; + + string arguments = TestApplication.GetArtifactPostProcessingLaunchArguments(appHostModule); + + arguments.Should().BeEmpty(); + } + + private static TerminalTestReporter CreateReporter(CapturingConsole console) + { + var reporter = new TerminalTestReporter(console, new TerminalTestReporterOptions + { + AnsiMode = AnsiMode.SimpleAnsi, + ShowProgress = false, + }); + reporter.TestExecutionStarted( + DateTimeOffset.UtcNow, + workerCount: 1, + isDiscovery: false, + isHelp: false, + isRetry: false); + return reporter; + } + + private static ArtifactPostProcessingApplication CreateApplication() + { + var module = new TestModule( + new RunProperties("dotnet", "A.dll", null), + ProjectFullPath: null, + TargetFramework: "net10.0", + IsTestingPlatformApplication: true, + LaunchSettings: null, + TargetPath: "A.dll", + DotnetRootArchVariableName: null, + EnvironmentVariables: new Dictionary()); + return new ArtifactPostProcessingApplication( + module, + "net10.0", + "x64", + new HashSet(StringComparer.Ordinal) { "microsoft.testing.trx", "microsoft.codecoverage" }, + new HashSet(StringComparer.Ordinal)); + } + + private static ArtifactPostProcessingArtifact CreateArtifact(string path, string? kind) + => new(path, kind, "A.dll", "net10.0", "x64", Guid.NewGuid().ToString("N")); +} diff --git a/test/dotnet.Tests/CommandTests/Test/ArtifactPostProcessingPlannerTests.cs b/test/dotnet.Tests/CommandTests/Test/ArtifactPostProcessingPlannerTests.cs new file mode 100644 index 000000000000..c1debaad7193 --- /dev/null +++ b/test/dotnet.Tests/CommandTests/Test/ArtifactPostProcessingPlannerTests.cs @@ -0,0 +1,243 @@ +// 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; + +[TestClass] +public class ArtifactPostProcessingPlannerTests +{ + [TestMethod] + public void Plan_OneApplicationCoversAllGroups_CreatesOneJob() + { + ArtifactPostProcessingApplication application = CreateApplication( + "A.dll", + "net10.0", + "x64", + ["microsoft.testing.trx", "example.junit"], + []); + ArtifactPostProcessingArtifact[] artifacts = + [ + CreateArtifact("A-1.trx", "microsoft.testing.trx", "A.dll", "x64"), + CreateArtifact("B-1.trx", "microsoft.testing.trx", "B.dll", "x64"), + CreateArtifact("A-1.xml", "example.junit", "A.dll", "x64"), + CreateArtifact("B-1.xml", "example.junit", "B.dll", "x64"), + ]; + + ArtifactPostProcessingPlan plan = ArtifactPostProcessingPlanner.Plan([application], artifacts); + + plan.Jobs.Should().ContainSingle(); + plan.Jobs[0].Application.Should().BeSameAs(application); + plan.Jobs[0].Groups.Select(group => group.Key) + .Should().BeEquivalentTo("microsoft.testing.trx", "example.junit"); + } + + [TestMethod] + public void Plan_ApplicationCoveringMostGroups_WinsMinimalSetCover() + { + ArtifactPostProcessingApplication trxOnly = CreateApplication( + "A.dll", + "net10.0", + "x64", + ["microsoft.testing.trx"], + []); + ArtifactPostProcessingApplication both = CreateApplication( + "B.dll", + "net9.0", + "x64", + ["microsoft.testing.trx", "example.junit"], + []); + ArtifactPostProcessingArtifact[] artifacts = + [ + CreateArtifact("A.trx", "microsoft.testing.trx", "A.dll", "x64"), + CreateArtifact("B.trx", "microsoft.testing.trx", "B.dll", "x64"), + CreateArtifact("A.xml", "example.junit", "A.dll", "x64"), + CreateArtifact("B.xml", "example.junit", "B.dll", "x64"), + ]; + + ArtifactPostProcessingPlan plan = ArtifactPostProcessingPlanner.Plan([trxOnly, both], artifacts); + + plan.Jobs.Should().ContainSingle(); + plan.Jobs[0].Application.Should().BeSameAs(both); + } + + [TestMethod] + public void Plan_SplitCapabilities_CreatesOneJobPerApplication() + { + ArtifactPostProcessingApplication trx = CreateApplication( + "A.dll", + "net10.0", + "x64", + ["microsoft.testing.trx"], + []); + ArtifactPostProcessingApplication junit = CreateApplication( + "B.dll", + "net10.0", + "x64", + ["example.junit"], + []); + ArtifactPostProcessingArtifact[] artifacts = + [ + CreateArtifact("A.trx", "microsoft.testing.trx", "A.dll", "x64"), + CreateArtifact("B.trx", "microsoft.testing.trx", "B.dll", "x64"), + CreateArtifact("A.xml", "example.junit", "A.dll", "x64"), + CreateArtifact("B.xml", "example.junit", "B.dll", "x64"), + ]; + + ArtifactPostProcessingPlan plan = ArtifactPostProcessingPlanner.Plan([trx, junit], artifacts); + + plan.Jobs.Should().HaveCount(2); + plan.Jobs.Select(job => job.Application).Should().Contain(trx).And.Contain(junit); + } + + [TestMethod] + public void Plan_UntaggedArtifacts_UsesExtensionFallback() + { + ArtifactPostProcessingApplication application = CreateApplication( + "A.dll", + "net10.0", + "x64", + [], + [".trx"]); + ArtifactPostProcessingArtifact[] artifacts = + [ + CreateArtifact("A.TRX", kind: null, "A.dll", "x64"), + CreateArtifact("B.trx", kind: null, "B.dll", "x64"), + ]; + + ArtifactPostProcessingPlan plan = ArtifactPostProcessingPlanner.Plan([application], artifacts); + + plan.Jobs.Should().ContainSingle(); + plan.Jobs[0].Groups.Should().ContainSingle(); + plan.Jobs[0].Groups[0].Key.Should().Be(".trx"); + plan.Jobs[0].Groups[0].IsKind.Should().BeFalse(); + } + + [TestMethod] + public void Plan_TaggedAndLegacyArtifactsTogether_MeetMergeThreshold() + { + ArtifactPostProcessingApplication application = CreateApplication( + "A.dll", + "net10.0", + "x64", + ["microsoft.testing.trx"], + [".trx"]); + ArtifactPostProcessingArtifact[] artifacts = + [ + CreateArtifact("A.trx", "microsoft.testing.trx", "A.dll", "x64"), + CreateArtifact("B.trx", kind: null, "B.dll", "x64"), + ]; + + ArtifactPostProcessingPlan plan = ArtifactPostProcessingPlanner.Plan([application], artifacts); + + plan.Jobs.Should().ContainSingle(); + plan.Jobs[0].Groups.Should().HaveCount(2); + plan.Jobs[0].Groups.SelectMany(group => group.Artifacts).Should().HaveCount(2); + } + + [TestMethod] + public void Plan_OneArtifactOrNoCapability_CreatesNoJobs() + { + ArtifactPostProcessingApplication application = CreateApplication( + "A.dll", + "net10.0", + "x64", + ["microsoft.testing.trx"], + []); + + ArtifactPostProcessingPlan oneArtifact = ArtifactPostProcessingPlanner.Plan( + [application], + [CreateArtifact("A.trx", "microsoft.testing.trx", "A.dll", "x64")]); + ArtifactPostProcessingPlan unsupported = ArtifactPostProcessingPlanner.Plan( + [application], + [ + CreateArtifact("A.xml", "example.junit", "A.dll", "x64"), + CreateArtifact("B.xml", "example.junit", "B.dll", "x64"), + ]); + + oneArtifact.Jobs.Should().BeEmpty(); + unsupported.Jobs.Should().BeEmpty(); + } + + [TestMethod] + public void Plan_SameArtifactReportedTwice_DoesNotCreateJob() + { + ArtifactPostProcessingApplication application = CreateApplication( + "A.dll", + "net10.0", + "x64", + ["microsoft.testing.trx"], + []); + ArtifactPostProcessingArtifact artifact = + CreateArtifact("A.trx", "microsoft.testing.trx", "A.dll", "x64"); + + ArtifactPostProcessingPlan plan = ArtifactPostProcessingPlanner.Plan( + [application], + [artifact, artifact with { ExecutionId = "another-execution" }]); + + plan.Jobs.Should().BeEmpty(); + } + + [TestMethod] + public void Plan_CodeCoverage_RequiresArchitectureCompatibleApplication() + { + ArtifactPostProcessingApplication x64 = CreateApplication( + "A.dll", + "net10.0", + "x64", + ["microsoft.codecoverage"], + []); + ArtifactPostProcessingApplication arm64 = CreateApplication( + "B.dll", + "net10.0", + "arm64", + ["microsoft.codecoverage"], + []); + ArtifactPostProcessingArtifact[] artifacts = + [ + CreateArtifact("A.coverage", "microsoft.codecoverage", "A.dll", "arm64"), + CreateArtifact("B.coverage", "microsoft.codecoverage", "B.dll", "arm64"), + ]; + + ArtifactPostProcessingPlan plan = ArtifactPostProcessingPlanner.Plan([x64, arm64], artifacts); + + plan.Jobs.Should().ContainSingle(); + plan.Jobs[0].Application.Should().BeSameAs(arm64); + } + + private static ArtifactPostProcessingApplication CreateApplication( + string targetPath, + string targetFramework, + string architecture, + string[] kinds, + string[] extensions) + => new( + new TestModule( + new RunProperties("dotnet", targetPath, null), + ProjectFullPath: null, + TargetFramework: targetFramework, + IsTestingPlatformApplication: true, + LaunchSettings: null, + TargetPath: targetPath, + DotnetRootArchVariableName: null, + EnvironmentVariables: new Dictionary()), + targetFramework, + architecture, + new HashSet(kinds, StringComparer.Ordinal), + new HashSet(extensions, StringComparer.Ordinal)); + + private static ArtifactPostProcessingArtifact CreateArtifact( + string path, + string? kind, + string producingTestModule, + string architecture) + => new( + path, + kind, + producingTestModule, + "net10.0", + architecture, + Guid.NewGuid().ToString("N")); +} diff --git a/test/dotnet.Tests/CommandTests/Test/GivenDotnetTestBuildsAndRunsArtifactPostProcessingMTP.cs b/test/dotnet.Tests/CommandTests/Test/GivenDotnetTestBuildsAndRunsArtifactPostProcessingMTP.cs new file mode 100644 index 000000000000..68a9731b1d15 --- /dev/null +++ b/test/dotnet.Tests/CommandTests/Test/GivenDotnetTestBuildsAndRunsArtifactPostProcessingMTP.cs @@ -0,0 +1,164 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.RegularExpressions; +using System.Xml.Linq; +using Microsoft.DotNet.Cli.Commands.Test; +using Microsoft.DotNet.Cli.Utils; +using ExitCodes = Microsoft.NET.TestFramework.ExitCode; + +namespace Microsoft.DotNet.Cli.Test.Tests; + +[TestClass] +public class GivenDotnetTestBuildsAndRunsArtifactPostProcessingMTP : SdkTest +{ + [TestMethod] + public void MultiProjectRun_MergesTrxArtifacts() + { + TestAsset testInstance = TestAssetsManager + .CopyTestAsset("MultiTestProjectSolutionWithTests", Guid.NewGuid().ToString()) + .WithSource(); + EnableTrxReport(testInstance.Path); + string resultsDirectory = Path.Combine(testInstance.Path, "TestResults"); + + CommandResult firstResult = Run(testInstance.Path, resultsDirectory); + string firstMergedTrxPath = GetMergedTrxPath(firstResult); + + File.Exists(firstMergedTrxPath).Should().BeTrue(); + Path.GetFileName(firstMergedTrxPath).Should().MatchRegex("^merged-[0-9a-f]{32}\\.trx$"); + Directory.GetFiles(resultsDirectory, "*.trx", SearchOption.AllDirectories) + .Should().HaveCount(3, "the two original reports remain on disk beside the merged report"); + + CommandResult secondResult = Run(testInstance.Path, resultsDirectory); + string secondMergedTrxPath = GetMergedTrxPath(secondResult); + + secondMergedTrxPath.Should().NotBe( + firstMergedTrxPath, + "each invocation has distinct execution IDs and must produce a non-colliding merged report"); + File.Exists(secondMergedTrxPath).Should().BeTrue(); + Directory.GetFiles(resultsDirectory, "*.trx", SearchOption.AllDirectories) + .Should().HaveCount(4, "the original reports are overwritten while each merged run is preserved"); + + XNamespace ns = "http://microsoft.com/schemas/VisualStudio/TeamTest/2010"; + XDocument mergedTrx = XDocument.Load(secondMergedTrxPath); + mergedTrx.Descendants(ns + "Counters").Single().Attribute("total")!.Value.Should().Be("5"); + } + + private CommandResult Run(string workingDirectory, string resultsDirectory) + => new DotnetTestCommand(Log, disableNewOutput: false) + .WithWorkingDirectory(workingDirectory) + .Execute( + "--report-trx", + "--results-directory", resultsDirectory, + "--configuration", TestingConstants.Debug); + + private static string GetMergedTrxPath(CommandResult result) + { + result.ExitCode.Should().Be( + ExitCodes.AtLeastOneTestFailed, + $"the test output was:{Environment.NewLine}{result.StdOut}{Environment.NewLine}{result.StdErr}"); + + MatchCollection artifactMatches = Regex.Matches( + result.StdOut ?? string.Empty, + @"(?m)^\s*-\s+(?.*\.trx)\s*$", + RegexOptions.CultureInvariant); + artifactMatches.Should().ContainSingle(); + + string mergedTrxPath = artifactMatches[0].Groups["path"].Value; + return mergedTrxPath; + } + + private static void EnableTrxReport(string testAssetPath) + { + foreach (string projectPath in Directory.GetFiles(testAssetPath, "*TestProject.csproj", SearchOption.AllDirectories)) + { + XDocument project = XDocument.Load(projectPath); + XElement packageReferenceGroup = project.Root! + .Elements("ItemGroup") + .Single(group => group.Elements("PackageReference").Any()); + packageReferenceGroup.Elements("PackageReference") + .Single(reference => (string?)reference.Attribute("Include") == "Microsoft.Testing.Platform") + .SetAttributeValue("Version", "$(MicrosoftTestingPlatformVersion)"); + packageReferenceGroup.Add(new XElement( + "PackageReference", + new XAttribute("Include", "Microsoft.Testing.Extensions.TrxReport"), + new XAttribute("Version", "$(MicrosoftTestingPlatformVersion)"))); + project.Save(projectPath); + } + + foreach (string programPath in Directory.GetFiles(testAssetPath, "Program.cs", SearchOption.AllDirectories)) + { + string source = File.ReadAllText(programPath) + .Replace( + """ + for (int i = 0; i < 3; i++) + { + Console.WriteLine(new string('a', 10000)); + Console.Error.WriteLine(new string('e', 10000)); + } + + """, + string.Empty, + StringComparison.Ordinal) + .Replace( + "using Microsoft.Testing.Platform.Builder;", + """ + using Microsoft.Testing.Extensions; + using Microsoft.Testing.Extensions.TrxReport.Abstractions; + using Microsoft.Testing.Platform.Builder; + """, + StringComparison.Ordinal) + .Replace( + "new TestFrameworkCapabilities()", + "new TestFrameworkCapabilities(new TrxReportCapability())", + StringComparison.Ordinal) + .Replace( + "testApplicationBuilder.RegisterTestFramework", + """ + testApplicationBuilder.AddTrxReportProvider(); + + testApplicationBuilder.RegisterTestFramework + """, + StringComparison.Ordinal) + .Replace( + """ + public async Task ExecuteRequestAsync(ExecuteRequestContext context) + { + """, + """ + public async Task ExecuteRequestAsync(ExecuteRequestContext context) + { + var testMethodIdentifier = new TestMethodIdentifierProperty( + string.Empty, string.Empty, nameof(DummyTestAdapter), "Test", 0, [], string.Empty); + """, + StringComparison.Ordinal) + .Replace( + """new PassedTestNodeStateProperty("OK"))""", + """new PassedTestNodeStateProperty("OK"), testMethodIdentifier)""", + StringComparison.Ordinal) + .Replace( + """new SkippedTestNodeStateProperty("OK skipped!"))""", + """new SkippedTestNodeStateProperty("OK skipped!"), testMethodIdentifier)""", + StringComparison.Ordinal) + .Replace( + """new SkippedTestNodeStateProperty("skipped"))""", + """new SkippedTestNodeStateProperty("skipped"), testMethodIdentifier)""", + StringComparison.Ordinal) + .Replace( + """new FailedTestNodeStateProperty(new Exception("this is a failed test"), "not OK"))""", + """new FailedTestNodeStateProperty(new Exception("this is a failed test"), "not OK"), testMethodIdentifier)""", + StringComparison.Ordinal) + + """ + + public sealed class TrxReportCapability : ITrxReportCapability + { + bool ITrxReportCapability.IsSupported => true; + void ITrxReportCapability.Enable() + { + } + } + """; + File.WriteAllText(programPath, source); + } + } +} diff --git a/test/dotnet.Tests/CommandTests/Test/TestApplicationHandlerTests.cs b/test/dotnet.Tests/CommandTests/Test/TestApplicationHandlerTests.cs index e924b970a243..29f2e791eb5e 100644 --- a/test/dotnet.Tests/CommandTests/Test/TestApplicationHandlerTests.cs +++ b/test/dotnet.Tests/CommandTests/Test/TestApplicationHandlerTests.cs @@ -156,6 +156,75 @@ public void OnHandshakeReceived_WithExplicitAttemptNumber_UsesReportedAttempt() console.GetOutput().Should().Contain("(try 3)"); } + [TestMethod] + public void OnHandshakeReceived_WithArtifactPostProcessingCapabilities_RecordsApplication() + { + var manager = new ArtifactPostProcessingManager(); + (TestApplicationHandler handler, _, _) = CreateHandler( + isHelp: false, + isDiscovery: false, + artifactPostProcessingManager: manager); + var handshake = BuildHandshake( + executionMode: HandshakeMessageExecutionModes.Run, + supportedPostProcessorKinds: "microsoft.testing.trx;example.junit", + supportedPostProcessorExtensions: ".trx;.xml"); + + bool accepted = handler.OnHandshakeReceived(handshake, gotSupportedVersion: true); + + accepted.Should().BeTrue(); + ArtifactPostProcessingApplication application = manager.SnapshotApplications().Should().ContainSingle().Subject; + application.SupportedKinds.Should().BeEquivalentTo("microsoft.testing.trx", "example.junit"); + application.SupportedExtensions.Should().BeEquivalentTo(".trx", ".xml"); + } + + [TestMethod] + public void OnFileArtifactsReceived_RecordsArtifactMetadata() + { + var manager = new ArtifactPostProcessingManager(); + (TestApplicationHandler handler, _, _) = CreateHandler( + isHelp: false, + isDiscovery: false, + artifactPostProcessingManager: manager); + handler.OnHandshakeReceived( + BuildHandshake(HandshakeMessageExecutionModes.Run), + gotSupportedVersion: true).Should().BeTrue(); + string artifactPath = Path.GetFullPath("result.trx"); + + handler.OnFileArtifactsReceived(new FileArtifactMessages( + "exec-1", + "inst-1", + [new FileArtifactMessage(artifactPath, "TRX", null, null, null, null, "microsoft.testing.trx")])); + + ArtifactPostProcessingArtifact artifact = manager.SnapshotArtifacts().Should().ContainSingle().Subject; + artifact.Path.Should().Be(artifactPath); + artifact.Kind.Should().Be("microsoft.testing.trx"); + artifact.ProducingTestModule.Should().Be(TargetPath); + artifact.TargetFramework.Should().Be(TargetFramework); + artifact.Architecture.Should().Be("x64"); + artifact.ExecutionId.Should().Be("exec-1"); + } + + [TestMethod] + public void OnHandshakeReceived_WhenArtifactPostProcessorHandshakeFails_DoesNotFailTestRun() + { + var invocation = new ArtifactPostProcessingInvocation("manifest.json"); + (TestApplicationHandler handler, TerminalTestReporter reporter, _) = CreateHandler( + isHelp: false, + isDiscovery: false, + artifactPostProcessingInvocation: invocation); + + bool accepted = handler.OnHandshakeReceived( + BuildHandshake( + HandshakeMessageExecutionModes.Tool, + hostType: HandshakeMessageHostTypes.TestHost), + gotSupportedVersion: true); + + accepted.Should().BeFalse(); + invocation.FailureMessage.Should().NotBeNullOrEmpty(); + reporter.HasHandshakeFailure.Should().BeFalse( + "post-processing failures must not change the test run exit code"); + } + /// /// Drives the new validation added in this change: if the host reports an execution mode that /// doesn't match what dotnet test intended (e.g. RunArguments or @@ -318,7 +387,12 @@ public void OnTestProcessExited_WhenSdkInHelpModeAndNoHandshakeReceived_DoesNotR private const string ProjectPath = "/repo/MyTest.csproj"; private const string TargetFramework = "net9.0"; - private (TestApplicationHandler Handler, TerminalTestReporter Reporter, CapturingConsole Console) CreateHandler(bool isHelp, bool isDiscovery, bool showAssembly = false) + private (TestApplicationHandler Handler, TerminalTestReporter Reporter, CapturingConsole Console) CreateHandler( + bool isHelp, + bool isDiscovery, + bool showAssembly = false, + ArtifactPostProcessingManager? artifactPostProcessingManager = null, + ArtifactPostProcessingInvocation? artifactPostProcessingInvocation = null) { var capturingConsole = new CapturingConsole(); @@ -348,12 +422,30 @@ public void OnTestProcessExited_WhenSdkInHelpModeAndNoHandshakeReceived_DoesNotR DotnetRootArchVariableName: null, EnvironmentVariables: new Dictionary()); - var testOptions = new TestOptions(IsHelp: isHelp, IsDiscovery: isDiscovery, ListTestsFormat: TestListFormat.Text); - - return (new TestApplicationHandler(reporter, module, testOptions), reporter, capturingConsole); + var testOptions = new TestOptions( + IsHelp: isHelp, + IsDiscovery: isDiscovery, + ListTestsFormat: TestListFormat.Text, + IsArtifactPostProcessing: artifactPostProcessingInvocation is not null); + + return ( + new TestApplicationHandler( + reporter, + module, + testOptions, + artifactPostProcessingManager, + artifactPostProcessingInvocation), + reporter, + capturingConsole); } - private static HandshakeMessage BuildHandshake(string? executionMode, string hostType = "TestHost", bool includeInstanceId = true, int? attemptNumber = null) + private static HandshakeMessage BuildHandshake( + string? executionMode, + string hostType = "TestHost", + bool includeInstanceId = true, + int? attemptNumber = null, + string? supportedPostProcessorKinds = null, + string? supportedPostProcessorExtensions = null) { var properties = new Dictionary { @@ -382,6 +474,16 @@ private static HandshakeMessage BuildHandshake(string? executionMode, string hos properties[HandshakeMessagePropertyNames.AttemptNumber] = attemptNumber.Value.ToString(CultureInfo.InvariantCulture); } + if (supportedPostProcessorKinds is not null) + { + properties[HandshakeMessagePropertyNames.SupportedPostProcessorKinds] = supportedPostProcessorKinds; + } + + if (supportedPostProcessorExtensions is not null) + { + properties[HandshakeMessagePropertyNames.SupportedPostProcessorExtensionsLegacy] = supportedPostProcessorExtensions; + } + return new HandshakeMessage(properties); } }