diff --git a/documentation/general/dotnet-test-artifact-post-processing.md b/documentation/general/dotnet-test-artifact-post-processing.md index f8a74b599a7b..3616ffda6ccb 100644 --- a/documentation/general/dotnet-test-artifact-post-processing.md +++ b/documentation/general/dotnet-test-artifact-post-processing.md @@ -33,11 +33,12 @@ when any of the following is true: - `--list-tests` was requested (test discovery, not execution). - `--no-artifact-post-processing` was passed. - The run was cancelled with Ctrl+C. -- The run was cut short by `--maximum-failed-tests` or `--timeout`. A truncated run produced - the artifacts of a truncated run — modules that never started contributed nothing, and - modules killed mid-flight wrote whatever they had — so merging them into one - authoritative-looking report would hide the truncation. The per-module artifacts are left - as they are. + +Runs cut short by `--maximum-failed-tests` or `--timeout` are handled differently. The SDK +plans only artifact groups whose processor explicitly advertised support for policy-truncated +runs. Other groups remain untouched, preventing an authoritative merger such as TRX or coverage +from making an incomplete run look complete while still allowing summary-style processors to +describe the truncation accurately. Post-processing can never change the run's exit code; it only affects which artifacts are listed. See [Failure behavior](#failure-behavior). @@ -55,10 +56,13 @@ The grouping and election logic lives in During its handshake, each MTP test application reports the post-processors registered inside it through two properties: `SupportedPostProcessorKinds` (reverse-DNS artifact *kinds*, e.g. `microsoft.testing.trx`) and `SupportedPostProcessorExtensionsLegacy` (lowercase file -extensions, for producers that do not tag a kind). Both are semicolon-separated. An -application that advertises neither simply never participates — it is neither a candidate to -perform a merge nor a source of mergeable groups. The handshake property ids are defined in -[`CliConstants`](../../src/Cli/dotnet/Commands/Test/CliConstants.cs). +extensions, for producers that do not tag a kind). Both are semicolon-separated. +`SupportedTruncatedRunPostProcessorKinds` and +`SupportedTruncatedRunPostProcessorExtensionsLegacy` advertise the subsets whose processors +can consume the incomplete set of complete artifacts observed before +`--maximum-failed-tests` or `--timeout` stopped the run. An application that advertises no +capability applicable to the current run does not participate. The handshake property ids are +defined in [`CliConstants`](../../src/Cli/dotnet/Commands/Test/CliConstants.cs). ### Grouping @@ -68,9 +72,10 @@ Artifacts are de-duplicated by path and then grouped: 2. **Extension fallback.** Artifacts with no kind are grouped by lowercase file extension. A group is a merge candidate only if a compatible application advertised the matching kind or -extension, and only if it has **at least two inputs** — merging a single file is pointless. (A -kind group and a matching extension group that individually have one input each can still be -merged together when a shared application supports both and the combined count reaches two.) +extension for the current run's completeness, and only if it has **at least two inputs** — +merging a single file is pointless. (A kind group and a matching extension group that +individually have one input each can still be merged together when a shared application supports +both and the combined count reaches two.) For binary code-coverage artifacts (kind `microsoft.codecoverage` / extension `.coverage`), an application is a candidate only when its architecture matches the inputs. Coverage blobs @@ -99,10 +104,16 @@ and manifest option are defined in The SDK writes a JSON manifest listing the input artifacts (path, kind, producing module, target framework, architecture, execution id) and the output directory, then hands it to the -relaunched tool. The merged artifacts flow **back over the same pipe** as ordinary -file-artifact messages, so they re-enter the normal reporter path. In the summary, the SDK -[removes the inputs it consumed and adds the merged output](../../src/Cli/dotnet/Commands/Test/MTP/ArtifactPostProcessingManager.cs) -in their place. +relaunched tool. On a policy-truncated run, the manifest also contains `truncationReason` with +the value `maximumFailedTests` or `timeout`; Microsoft.Testing.Platform exposes that through +`ArtifactPostProcessingContext`. + +The processed artifacts flow **back over the same pipe** as ordinary file-artifact messages, +along with the exact input paths each output consumed, so they re-enter the normal reporter +path. In the summary, the SDK +[removes only those consumed inputs and adds the processed output](../../src/Cli/dotnet/Commands/Test/MTP/ArtifactPostProcessingManager.cs) +in their place. Older hosts that do not report input provenance retain the previous +kind/extension-based replacement behavior. The original per-module artifacts are **never deleted from disk** — only the run summary changes. If you need the individual files (for example a per-module TRX), they are still where @@ -149,8 +160,9 @@ The relevant public types live in `Microsoft.Testing.Platform.Extensions.Artifac | Type | Role | |---|---| -| `IArtifactPostProcessor` | The contract: `SupportedKinds`, `SupportedFileExtensionsFallback`, and `ProcessAsync(inputs, outputDirectory, cancellationToken)`. | +| `IArtifactPostProcessor` | The contract: `SupportsTruncatedRuns`, `SupportedKinds`, `SupportedFileExtensionsFallback`, and `ProcessAsync(inputs, outputDirectory, context, cancellationToken)`. | | `IArtifactPostProcessingManager` | Registration, via `AddArtifactPostProcessor(Func)`. | +| `ArtifactPostProcessingContext` | Whether the run was truncated and whether the reason was `MaximumFailedTests` or `Timeout`. | | `InputArtifact` | One input: path, kind, producing test module, target framework, architecture, execution id. | | `ProcessedArtifact` | The merged result: path, kind, display name, description. | @@ -168,13 +180,17 @@ internal sealed class MyArtifactPostProcessor : IArtifactPostProcessor public string DisplayName => "Contoso report merger"; public string Description => "Merges Contoso reports."; + public bool SupportsTruncatedRuns => false; public IReadOnlyList SupportedKinds { get; } = ["contoso.myreport"]; public IReadOnlyList SupportedFileExtensionsFallback { get; } = [".myreport"]; public Task IsEnabledAsync() => Task.FromResult(true); public async Task ProcessAsync( - IReadOnlyList inputs, string outputDirectory, CancellationToken cancellationToken) + IReadOnlyList inputs, + string outputDirectory, + ArtifactPostProcessingContext context, + CancellationToken cancellationToken) { if (inputs.Count < 2) { @@ -202,7 +218,12 @@ What `dotnet test` expects of a processor: - **Treat inputs as read-only** and write under the supplied `outputDirectory`. Never return one of the inputs as your output, and never delete a source file. - **Set `Kind` on the artifact you return.** That is what the SDK uses to decide which originals the - merged artifact replaced in the run summary. + processed artifact represents. Microsoft.Testing.Platform separately reports the exact input + paths it consumed so the SDK removes only those originals from the run summary. +- **Set `SupportsTruncatedRuns` only when the output can accurately describe an incomplete run.** + When enabled, inspect `context.TruncationReason` and make the truncation visible in the result. + The capability covers an incomplete set of complete artifacts, not malformed or partially + written files. - **Be deterministic**: the same set of inputs should produce the same output path. Two SDK behaviors are worth knowing about. The SDK relaunches the *fewest* test applications that diff --git a/src/Cli/dotnet/Commands/Test/CliConstants.cs b/src/Cli/dotnet/Commands/Test/CliConstants.cs index 2eec642be509..16f2f2fc8812 100644 --- a/src/Cli/dotnet/Commands/Test/CliConstants.cs +++ b/src/Cli/dotnet/Commands/Test/CliConstants.cs @@ -98,6 +98,8 @@ internal static class HandshakeMessagePropertyNames // supported by post-processors registered in the test application. internal const byte SupportedPostProcessorKinds = 14; internal const byte SupportedPostProcessorExtensionsLegacy = 15; + internal const byte SupportedTruncatedRunPostProcessorKinds = 16; + internal const byte SupportedTruncatedRunPostProcessorExtensionsLegacy = 17; } internal static class HandshakeMessageExecutionModes diff --git a/src/Cli/dotnet/Commands/Test/MTP/ArtifactPostProcessingManager.cs b/src/Cli/dotnet/Commands/Test/MTP/ArtifactPostProcessingManager.cs index a24e03a849c2..3ec0324f97aa 100644 --- a/src/Cli/dotnet/Commands/Test/MTP/ArtifactPostProcessingManager.cs +++ b/src/Cli/dotnet/Commands/Test/MTP/ArtifactPostProcessingManager.cs @@ -25,8 +25,19 @@ public void RecordCapabilities( string[] extensions = ParseCapabilities(handshakeMessage, HandshakeMessagePropertyNames.SupportedPostProcessorExtensionsLegacy) .Select(extension => extension.ToLowerInvariant()) .ToArray(); + string[] truncatedRunKinds = ParseCapabilities( + handshakeMessage, + HandshakeMessagePropertyNames.SupportedTruncatedRunPostProcessorKinds); + string[] truncatedRunExtensions = ParseCapabilities( + handshakeMessage, + HandshakeMessagePropertyNames.SupportedTruncatedRunPostProcessorExtensionsLegacy) + .Select(extension => extension.ToLowerInvariant()) + .ToArray(); - if (kinds.Length == 0 && extensions.Length == 0) + if (kinds.Length == 0 + && extensions.Length == 0 + && truncatedRunKinds.Length == 0 + && truncatedRunExtensions.Length == 0) { return; } @@ -41,6 +52,8 @@ public void RecordCapabilities( application.SupportedKinds.UnionWith(kinds); application.SupportedExtensions.UnionWith(extensions); + application.SupportedTruncatedRunKinds.UnionWith(truncatedRunKinds); + application.SupportedTruncatedRunExtensions.UnionWith(truncatedRunExtensions); } } @@ -59,18 +72,20 @@ public void RecordArtifact( module.TargetPath, targetFramework, architecture, - executionId)); + executionId, + artifact.InputArtifactPaths)); } } public async Task ExecuteAsync( BuildOptions buildOptions, TerminalTestReporter output, - CtrlCCancellationManager ctrlC) + CtrlCCancellationManager ctrlC, + TestRunCancellationReason cancellationReason) { try { - await ExecuteCoreAsync(buildOptions, output, ctrlC); + await ExecuteCoreAsync(buildOptions, output, ctrlC, cancellationReason); } catch (Exception ex) { @@ -85,11 +100,13 @@ public async Task ExecuteAsync( private async Task ExecuteCoreAsync( BuildOptions buildOptions, TerminalTestReporter output, - CtrlCCancellationManager ctrlC) + CtrlCCancellationManager ctrlC, + TestRunCancellationReason cancellationReason) { ArtifactPostProcessingPlan plan = ArtifactPostProcessingPlanner.Plan( SnapshotApplications(), - SnapshotArtifacts()); + SnapshotArtifacts(), + cancellationReason); ArtifactPostProcessingJob[] runnableJobs = [ .. plan.Jobs.Where(job => @@ -138,7 +155,11 @@ .. plan.Jobs.Where(job => string manifestPath = Path.Combine(tempDirectory, "manifest.json"); string outputDirectory = GetOutputDirectory(buildOptions, job); Directory.CreateDirectory(outputDirectory); - WriteManifest(manifestPath, outputDirectory, job.Groups.SelectMany(group => group.Artifacts)); + WriteManifest( + manifestPath, + outputDirectory, + job.Groups.SelectMany(group => group.Artifacts), + cancellationReason); var invocation = new ArtifactPostProcessingInvocation(manifestPath); var toolOptions = new TestOptions( @@ -246,7 +267,9 @@ internal IReadOnlyList SnapshotApplications() application.TargetFramework, application.Architecture, new HashSet(application.SupportedKinds, StringComparer.Ordinal), - new HashSet(application.SupportedExtensions, StringComparer.Ordinal))) + new HashSet(application.SupportedExtensions, StringComparer.Ordinal), + new HashSet(application.SupportedTruncatedRunKinds, StringComparer.Ordinal), + new HashSet(application.SupportedTruncatedRunExtensions, StringComparer.Ordinal))) ]; } } @@ -304,10 +327,11 @@ .. job.Groups return Path.GetDirectoryName(Path.GetFullPath(preferredInput.Path))!; } - private static void WriteManifest( + internal static void WriteManifest( string manifestPath, string outputDirectory, - IEnumerable artifacts) + IEnumerable artifacts, + TestRunCancellationReason cancellationReason) { using FileStream stream = File.Create(manifestPath); using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true }); @@ -315,6 +339,18 @@ private static void WriteManifest( writer.WriteStartObject(); writer.WriteNumber("schemaVersion", 1); writer.WriteString("outputDirectory", outputDirectory); + if (cancellationReason != TestRunCancellationReason.None) + { + writer.WriteString( + "truncationReason", + cancellationReason switch + { + TestRunCancellationReason.MaximumFailedTests => "maximumFailedTests", + TestRunCancellationReason.Timeout => "timeout", + _ => throw new ArgumentOutOfRangeException(nameof(cancellationReason)), + }); + } + writer.WriteStartArray("inputs"); foreach (ArtifactPostProcessingArtifact artifact in artifacts .OrderBy(artifact => artifact.Path, FileUtilities.PathComparer)) @@ -350,24 +386,36 @@ internal static void ApplyOutputs( ArtifactPostProcessingJob job, IReadOnlyList processedArtifacts) { + var plannedInputPaths = new HashSet( + job.Groups.SelectMany(group => group.Artifacts).Select(artifact => artifact.Path), + FileUtilities.PathComparer); + 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) + HashSet consumedPaths; + if (processedArtifact.InputArtifactPaths is { } inputArtifactPaths) + { + consumedPaths = new HashSet(inputArtifactPaths, FileUtilities.PathComparer); + consumedPaths.IntersectWith(plannedInputPaths); + } + else { - var consumedPaths = new HashSet( - consumedGroups.SelectMany(group => group.Artifacts).Select(artifact => artifact.Path), + string outputExtension = Path.GetExtension(processedArtifact.Path).ToLowerInvariant(); + // Older post-processing hosts do not report input provenance. Preserve their + // replacement behavior by inferring consumed groups from the output kind/extension. + consumedPaths = new HashSet( + job.Groups + .Where(group => + group.IsKind + ? string.Equals(group.Key, processedArtifact.Kind, StringComparison.Ordinal) + : string.Equals(group.Key, outputExtension, StringComparison.Ordinal)) + .SelectMany(group => group.Artifacts) + .Select(artifact => artifact.Path), FileUtilities.PathComparer); + } + + if (consumedPaths.Count > 0) + { output.RemoveArtifacts(consumedPaths); } @@ -389,6 +437,8 @@ private sealed class ApplicationState(TestModule module, string? targetFramework public string? Architecture { get; } = architecture; public HashSet SupportedKinds { get; } = new(StringComparer.Ordinal); public HashSet SupportedExtensions { get; } = new(StringComparer.Ordinal); + public HashSet SupportedTruncatedRunKinds { get; } = new(StringComparer.Ordinal); + public HashSet SupportedTruncatedRunExtensions { get; } = new(StringComparer.Ordinal); } } @@ -434,7 +484,8 @@ public void RecordOutput( module.TargetPath, targetFramework, architecture, - executionId)); + executionId, + artifact.InputArtifactPaths)); } } diff --git a/src/Cli/dotnet/Commands/Test/MTP/ArtifactPostProcessingPlanner.cs b/src/Cli/dotnet/Commands/Test/MTP/ArtifactPostProcessingPlanner.cs index 7eab9a007cdc..427cde9d3981 100644 --- a/src/Cli/dotnet/Commands/Test/MTP/ArtifactPostProcessingPlanner.cs +++ b/src/Cli/dotnet/Commands/Test/MTP/ArtifactPostProcessingPlanner.cs @@ -11,7 +11,9 @@ internal sealed record ArtifactPostProcessingApplication( string? TargetFramework, string? Architecture, IReadOnlySet SupportedKinds, - IReadOnlySet SupportedExtensions); + IReadOnlySet SupportedExtensions, + IReadOnlySet SupportedTruncatedRunKinds, + IReadOnlySet SupportedTruncatedRunExtensions); internal sealed record ArtifactPostProcessingArtifact( string Path, @@ -19,7 +21,8 @@ internal sealed record ArtifactPostProcessingArtifact( string ProducingTestModule, string? TargetFramework, string? Architecture, - string ExecutionId); + string ExecutionId, + IReadOnlyList? InputArtifactPaths = null); internal sealed record ArtifactPostProcessingGroup( string Key, @@ -40,8 +43,10 @@ internal static class ArtifactPostProcessingPlanner public static ArtifactPostProcessingPlan Plan( IReadOnlyList applications, - IReadOnlyList artifacts) + IReadOnlyList artifacts, + TestRunCancellationReason cancellationReason = TestRunCancellationReason.None) { + bool isTruncated = cancellationReason != TestRunCancellationReason.None; ArtifactPostProcessingArtifact[] distinctArtifacts = [.. artifacts.DistinctBy(artifact => artifact.Path, FileUtilities.PathComparer)]; List groups = []; @@ -109,9 +114,7 @@ void AddGroup(string key, bool isKind, ArtifactPostProcessingArtifact[] inputs) ArtifactPostProcessingApplication[] candidates = [ .. applications.Where(application => - (isKind - ? application.SupportedKinds.Contains(key) - : application.SupportedExtensions.Contains(key)) + GetCapabilities(application, isKind, isTruncated).Contains(key) && IsArchitectureCompatible(key, isKind, application, inputs)) ]; @@ -122,6 +125,18 @@ .. applications.Where(application => } } + private static IReadOnlySet GetCapabilities( + ArtifactPostProcessingApplication application, + bool isKind, + bool isTruncated) + => (isTruncated, isKind) switch + { + (true, true) => application.SupportedTruncatedRunKinds, + (true, false) => application.SupportedTruncatedRunExtensions, + (false, true) => application.SupportedKinds, + (false, false) => application.SupportedExtensions, + }; + private static bool CanCombineKindAndExtensionGroups( ArtifactPostProcessingGroup first, ArtifactPostProcessingGroup second) diff --git a/src/Cli/dotnet/Commands/Test/MTP/IPC/Models/FileArtifactMessages.cs b/src/Cli/dotnet/Commands/Test/MTP/IPC/Models/FileArtifactMessages.cs index c574a969e439..941a8d46afe1 100644 --- a/src/Cli/dotnet/Commands/Test/MTP/IPC/Models/FileArtifactMessages.cs +++ b/src/Cli/dotnet/Commands/Test/MTP/IPC/Models/FileArtifactMessages.cs @@ -3,6 +3,14 @@ namespace Microsoft.DotNet.Cli.Commands.Test.IPC.Models; -internal sealed record FileArtifactMessage(string? FullPath, string? DisplayName, string? Description, string? TestUid, string? TestDisplayName, string? SessionUid, string? Kind); +internal sealed record FileArtifactMessage( + string? FullPath, + string? DisplayName, + string? Description, + string? TestUid, + string? TestDisplayName, + string? SessionUid, + string? Kind, + string[]? InputArtifactPaths = null); internal sealed record FileArtifactMessages(string? ExecutionId, string? InstanceId, FileArtifactMessage[] FileArtifacts) : IRequest; diff --git a/src/Cli/dotnet/Commands/Test/MTP/IPC/ObjectFieldIds.cs b/src/Cli/dotnet/Commands/Test/MTP/IPC/ObjectFieldIds.cs index ca9f44bfa9ff..cc3ed7aa6e41 100644 --- a/src/Cli/dotnet/Commands/Test/MTP/IPC/ObjectFieldIds.cs +++ b/src/Cli/dotnet/Commands/Test/MTP/IPC/ObjectFieldIds.cs @@ -140,6 +140,7 @@ internal static class FileArtifactMessageFieldsId public const ushort TestDisplayName = 5; public const ushort SessionUid = 6; public const ushort Kind = 7; + public const ushort InputArtifactPaths = 8; } internal static class TestSessionEventFieldsId diff --git a/src/Cli/dotnet/Commands/Test/MTP/IPC/Serializers/FileArtifactMessagesSerializer.cs b/src/Cli/dotnet/Commands/Test/MTP/IPC/Serializers/FileArtifactMessagesSerializer.cs index de5ad1516f31..2497b345a393 100644 --- a/src/Cli/dotnet/Commands/Test/MTP/IPC/Serializers/FileArtifactMessagesSerializer.cs +++ b/src/Cli/dotnet/Commands/Test/MTP/IPC/Serializers/FileArtifactMessagesSerializer.cs @@ -51,6 +51,13 @@ namespace Microsoft.DotNet.Cli.Commands.Test.IPC.Serializers; |---FileArtifactMessageList[0].Kind Id---| (2 bytes) |---FileArtifactMessageList[0].Kind Size---| (4 bytes) |---FileArtifactMessageList[0].Kind Value---| (n bytes) + + |---FileArtifactMessageList[0].InputArtifactPaths Id---| (2 bytes) + |---FileArtifactMessageList[0].InputArtifactPaths Size---| (4 bytes) + |---FileArtifactMessageList[0].InputArtifactPaths Value---| (n bytes) + |---InputArtifactPaths Length---| (4 bytes) + |---InputArtifactPaths[0] Size---| (4 bytes) + |---InputArtifactPaths[0] Value---| (n bytes) */ internal sealed class FileArtifactMessagesSerializer : BaseSerializer, INamedPipeSerializer @@ -102,6 +109,7 @@ private static List ReadFileArtifactMessagesPayload(Stream for (int i = 0; i < length; i++) { string? fullPath = null, displayName = null, description = null, testUid = null, testDisplayName = null, sessionUid = null, kind = null; + string[]? inputArtifactPaths = null; int fieldCount = ReadUShort(stream); @@ -140,18 +148,42 @@ private static List ReadFileArtifactMessagesPayload(Stream kind = ReadStringValue(stream, fieldSize); break; + case FileArtifactMessageFieldsId.InputArtifactPaths: + inputArtifactPaths = ReadInputArtifactPathsPayload(stream); + break; + default: SetPosition(stream, stream.Position + fieldSize); break; } } - fileArtifactMessages.Add(new FileArtifactMessage(fullPath, displayName, description, testUid, testDisplayName, sessionUid, kind)); + fileArtifactMessages.Add(new FileArtifactMessage( + fullPath, + displayName, + description, + testUid, + testDisplayName, + sessionUid, + kind, + inputArtifactPaths)); } return fileArtifactMessages; } + private static string[] ReadInputArtifactPathsPayload(Stream stream) + { + int length = ReadInt(stream); + string[] inputArtifactPaths = new string[length]; + for (int i = 0; i < length; i++) + { + inputArtifactPaths[i] = ReadString(stream); + } + + return inputArtifactPaths; + } + public void Serialize(object objectToSerialize, Stream stream) { Debug.Assert(stream.CanSeek, "We expect a seekable stream."); @@ -191,6 +223,7 @@ private static void WriteFileArtifactMessagesPayload(Stream stream, FileArtifact WriteField(stream, FileArtifactMessageFieldsId.TestDisplayName, fileArtifactMessage.TestDisplayName); WriteField(stream, FileArtifactMessageFieldsId.SessionUid, fileArtifactMessage.SessionUid); WriteField(stream, FileArtifactMessageFieldsId.Kind, fileArtifactMessage.Kind); + WriteInputArtifactPathsPayload(stream, fileArtifactMessage.InputArtifactPaths); } // NOTE: We are able to seek only if we are using a MemoryStream @@ -198,6 +231,26 @@ private static void WriteFileArtifactMessagesPayload(Stream stream, FileArtifact WriteAtPosition(stream, (int)(stream.Position - before), before - sizeof(int)); } + private static void WriteInputArtifactPathsPayload(Stream stream, string[]? inputArtifactPaths) + { + if (inputArtifactPaths is null || inputArtifactPaths.Length == 0) + { + return; + } + + WriteUShort(stream, FileArtifactMessageFieldsId.InputArtifactPaths); + WriteInt(stream, 0); + + long before = stream.Position; + WriteInt(stream, inputArtifactPaths.Length); + foreach (string inputArtifactPath in inputArtifactPaths) + { + WriteString(stream, inputArtifactPath); + } + + WriteAtPosition(stream, (int)(stream.Position - before), before - sizeof(int)); + } + private static ushort GetFieldCount(FileArtifactMessages fileArtifactMessages) => (ushort)((fileArtifactMessages.ExecutionId is null ? 0 : 1) + (fileArtifactMessages.InstanceId is null ? 0 : 1) + @@ -210,5 +263,6 @@ private static ushort GetFieldCount(FileArtifactMessage fileArtifactMessage) => (fileArtifactMessage.TestUid is null ? 0 : 1) + (fileArtifactMessage.TestDisplayName is null ? 0 : 1) + (fileArtifactMessage.SessionUid is null ? 0 : 1) + - (fileArtifactMessage.Kind is null ? 0 : 1)); + (fileArtifactMessage.Kind is null ? 0 : 1) + + (IsNullOrEmpty(fileArtifactMessage.InputArtifactPaths) ? 0 : 1)); } diff --git a/src/Cli/dotnet/Commands/Test/MTP/MicrosoftTestingPlatformTestCommand.cs b/src/Cli/dotnet/Commands/Test/MTP/MicrosoftTestingPlatformTestCommand.cs index 1b6439305c5f..0ad5f7cb244c 100644 --- a/src/Cli/dotnet/Commands/Test/MTP/MicrosoftTestingPlatformTestCommand.cs +++ b/src/Cli/dotnet/Commands/Test/MTP/MicrosoftTestingPlatformTestCommand.cs @@ -195,10 +195,9 @@ public int Run(ParseResult parseResult, bool isHelp) if (ShouldPostProcessArtifacts( testOptions, parseResult.GetValue(definition.NoArtifactPostProcessingOption), - ctrlC.Token.IsCancellationRequested, - cancellationReason)) + ctrlC.Token.IsCancellationRequested)) { - artifactPostProcessingManager.ExecuteAsync(buildOptions, output, ctrlC).GetAwaiter().GetResult(); + artifactPostProcessingManager.ExecuteAsync(buildOptions, output, ctrlC, cancellationReason).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. @@ -240,23 +239,19 @@ public int Run(ParseResult parseResult, bool isHelp) /// Decides whether the artifacts of a finished run should be consolidated. /// /// - /// Help and discovery produce no artifacts to merge, and --no-artifact-post-processing is - /// the explicit opt-out. The two cancellation cases are the interesting ones: a run stopped by - /// Ctrl+C, --maximum-failed-tests or --timeout produced the artifacts of a - /// truncated run — modules that never started contributed nothing, and modules killed mid-flight - /// wrote whatever they had. Merging those into a single report would hide the truncation behind - /// one authoritative-looking artifact, so the per-module artifacts are left as they are. + /// Help and discovery produce no artifacts to merge, --no-artifact-post-processing is the + /// explicit opt-out, and Ctrl+C is an unconditional user cancellation. Policy-truncated runs + /// continue into planning, which selects only processors that explicitly advertised support for + /// the incomplete set of complete artifacts observed before cancellation. /// internal static bool ShouldPostProcessArtifacts( TestOptions testOptions, bool noArtifactPostProcessingRequested, - bool cancellationRequested, - TestRunCancellationReason cancellationReason) + bool cancellationRequested) => !testOptions.IsHelp && !testOptions.IsDiscovery && !noArtifactPostProcessingRequested - && !cancellationRequested - && cancellationReason == TestRunCancellationReason.None; + && !cancellationRequested; internal static (BuildOptions BuildOptions, bool CollectTestMap, bool AffectedTests) NormalizeForwardedAffectedTestsOptions( BuildOptions buildOptions) diff --git a/src/Cli/dotnet/Commands/Test/MTP/TestApplicationHandler.cs b/src/Cli/dotnet/Commands/Test/MTP/TestApplicationHandler.cs index fe5b334d57a0..1b547d815991 100644 --- a/src/Cli/dotnet/Commands/Test/MTP/TestApplicationHandler.cs +++ b/src/Cli/dotnet/Commands/Test/MTP/TestApplicationHandler.cs @@ -242,6 +242,8 @@ private static string GetHandshakePropertyName(byte propertyId) => HandshakeMessagePropertyNames.AttemptNumber => nameof(HandshakeMessagePropertyNames.AttemptNumber), HandshakeMessagePropertyNames.SupportedPostProcessorKinds => nameof(HandshakeMessagePropertyNames.SupportedPostProcessorKinds), HandshakeMessagePropertyNames.SupportedPostProcessorExtensionsLegacy => nameof(HandshakeMessagePropertyNames.SupportedPostProcessorExtensionsLegacy), + HandshakeMessagePropertyNames.SupportedTruncatedRunPostProcessorKinds => nameof(HandshakeMessagePropertyNames.SupportedTruncatedRunPostProcessorKinds), + HandshakeMessagePropertyNames.SupportedTruncatedRunPostProcessorExtensionsLegacy => nameof(HandshakeMessagePropertyNames.SupportedTruncatedRunPostProcessorExtensionsLegacy), _ => string.Empty, }; @@ -755,7 +757,8 @@ private static void LogFileArtifacts(FileArtifactMessages fileArtifactMessages) { logMessageBuilder.AppendLine($"FileArtifact: {fileArtifactMessage.FullPath}, {fileArtifactMessage.DisplayName}, " + $"{fileArtifactMessage.Description}, {fileArtifactMessage.TestUid}, {fileArtifactMessage.TestDisplayName}, " + - $"{fileArtifactMessage.SessionUid}, {fileArtifactMessage.Kind}"); + $"{fileArtifactMessage.SessionUid}, {fileArtifactMessage.Kind}, " + + $"InputArtifactPaths=[{string.Join(", ", fileArtifactMessage.InputArtifactPaths ?? [])}]"); } Logger.LogTrace(logMessageBuilder, static logMessageBuilder => logMessageBuilder.ToString()); diff --git a/test/dotnet.Tests/CommandTests/Test/ArtifactPostProcessingManagerTests.cs b/test/dotnet.Tests/CommandTests/Test/ArtifactPostProcessingManagerTests.cs index c90b60615986..7b805c4ce3ea 100644 --- a/test/dotnet.Tests/CommandTests/Test/ArtifactPostProcessingManagerTests.cs +++ b/test/dotnet.Tests/CommandTests/Test/ArtifactPostProcessingManagerTests.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Text; +using System.Text.Json; using Microsoft.DotNet.Cli.Commands; using Microsoft.DotNet.Cli.Commands.Run; using Microsoft.DotNet.Cli.Commands.Test; @@ -108,6 +109,36 @@ public void ApplyOutputs_KindOutput_AlsoConsumesLegacyInputsWithSameExtension() output.Should().NotContain("legacy-second.xml"); } + [TestMethod] + public void ApplyOutputs_WithInputProvenance_RemovesOnlyExactConsumedInputs() + { + 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", + inputArtifactPaths: [first.Path]); + + ArtifactPostProcessingManager.ApplyOutputs(reporter, job, [converted]); + reporter.TestExecutionCompleted(DateTimeOffset.UtcNow, TestExitCode.Success); + + string output = console.GetOutput(); + output.Should().Contain("coverage.cobertura.xml"); + output.Should().NotContain("first.coverage"); + output.Should().Contain("second.coverage"); + } + [TestMethod] public void GetArtifactPostProcessingLaunchArguments_DotnetCommand_UsesOnlyExecAndTargetPath() { @@ -220,8 +251,7 @@ public void ShouldPostProcessArtifacts_CompletedRun_MergesArtifacts() => MicrosoftTestingPlatformTestCommand.ShouldPostProcessArtifacts( CreateTestOptions(), noArtifactPostProcessingRequested: false, - cancellationRequested: false, - TestRunCancellationReason.None).Should().BeTrue(); + cancellationRequested: false).Should().BeTrue(); [TestMethod] public void ShouldPostProcessArtifacts_HelpOrDiscovery_MergesNothing() @@ -229,14 +259,12 @@ public void ShouldPostProcessArtifacts_HelpOrDiscovery_MergesNothing() MicrosoftTestingPlatformTestCommand.ShouldPostProcessArtifacts( CreateTestOptions(isHelp: true), noArtifactPostProcessingRequested: false, - cancellationRequested: false, - TestRunCancellationReason.None).Should().BeFalse("help prints usage and produces no artifacts"); + cancellationRequested: false).Should().BeFalse("help prints usage and produces no artifacts"); MicrosoftTestingPlatformTestCommand.ShouldPostProcessArtifacts( CreateTestOptions(isDiscovery: true), noArtifactPostProcessingRequested: false, - cancellationRequested: false, - TestRunCancellationReason.None).Should().BeFalse("discovery runs no tests and produces no artifacts"); + cancellationRequested: false).Should().BeFalse("discovery runs no tests and produces no artifacts"); } [TestMethod] @@ -244,31 +272,60 @@ public void ShouldPostProcessArtifacts_OptedOut_MergesNothing() => MicrosoftTestingPlatformTestCommand.ShouldPostProcessArtifacts( CreateTestOptions(), noArtifactPostProcessingRequested: true, - cancellationRequested: false, - TestRunCancellationReason.None).Should().BeFalse(); + cancellationRequested: false).Should().BeFalse(); [TestMethod] - public void ShouldPostProcessArtifacts_TruncatedRun_MergesNothing() - { - // Merging the artifacts of a run that was cut short would hide the truncation behind one - // authoritative-looking report, so every way of cutting a run short has to skip the merge. - MicrosoftTestingPlatformTestCommand.ShouldPostProcessArtifacts( + public void ShouldPostProcessArtifacts_CtrlC_MergesNothing() + => MicrosoftTestingPlatformTestCommand.ShouldPostProcessArtifacts( CreateTestOptions(), noArtifactPostProcessingRequested: false, - cancellationRequested: true, - TestRunCancellationReason.None).Should().BeFalse("Ctrl+C leaves a truncated run"); + cancellationRequested: true).Should().BeFalse("Ctrl+C is an unconditional user cancellation"); - MicrosoftTestingPlatformTestCommand.ShouldPostProcessArtifacts( - CreateTestOptions(), - noArtifactPostProcessingRequested: false, - cancellationRequested: false, - TestRunCancellationReason.MaximumFailedTests).Should().BeFalse("--maximum-failed-tests leaves a truncated run"); + [TestMethod] + [DataRow((int)TestRunCancellationReason.MaximumFailedTests, "maximumFailedTests")] + [DataRow((int)TestRunCancellationReason.Timeout, "timeout")] + public void WriteManifest_PolicyTruncatedRun_WritesTruncationReason( + int cancellationReason, + string expectedTruncationReason) + { + string manifestPath = Path.Combine(Path.GetTempPath(), $"manifest-{Guid.NewGuid():N}.json"); + try + { + ArtifactPostProcessingManager.WriteManifest( + manifestPath, + "/results", + [CreateArtifact("first.summary", "example.summary")], + (TestRunCancellationReason)cancellationReason); + + using JsonDocument manifest = JsonDocument.Parse(File.ReadAllText(manifestPath)); + manifest.RootElement.GetProperty("truncationReason").GetString() + .Should().Be(expectedTruncationReason); + } + finally + { + File.Delete(manifestPath); + } + } - MicrosoftTestingPlatformTestCommand.ShouldPostProcessArtifacts( - CreateTestOptions(), - noArtifactPostProcessingRequested: false, - cancellationRequested: false, - TestRunCancellationReason.Timeout).Should().BeFalse("--timeout leaves a truncated run"); + [TestMethod] + public void WriteManifest_CompletedRun_OmitsTruncationReason() + { + string manifestPath = Path.Combine(Path.GetTempPath(), $"manifest-{Guid.NewGuid():N}.json"); + try + { + ArtifactPostProcessingManager.WriteManifest( + manifestPath, + "/results", + [CreateArtifact("first.trx", "microsoft.testing.trx")], + TestRunCancellationReason.None); + + using JsonDocument manifest = JsonDocument.Parse(File.ReadAllText(manifestPath)); + manifest.RootElement.TryGetProperty("truncationReason", out _).Should().BeFalse(); + } + finally + { + File.Delete(manifestPath); + } } private static TestOptions CreateTestOptions(bool isHelp = false, bool isDiscovery = false) @@ -373,6 +430,8 @@ private static ArtifactPostProcessingJob CreateJob(TestModule module, params Art "net10.0", "x64", new HashSet(StringComparer.Ordinal) { "microsoft.testing.trx", "microsoft.codecoverage" }, + new HashSet(StringComparer.Ordinal), + new HashSet(StringComparer.Ordinal), new HashSet(StringComparer.Ordinal)); return new ArtifactPostProcessingJob( application, @@ -391,7 +450,11 @@ public async Task ExecuteAsync_WhenJobFailsUnexpectedly_ReportsWarningWithoutThr ArtifactPostProcessingManager manager = CreateManagerWithMergeableArtifacts("first\0.trx", "second\0.trx"); using var ctrlC = CreateCancellationManager(); - await manager.ExecuteAsync(CreateBuildOptions(), reporter, ctrlC); + await manager.ExecuteAsync( + CreateBuildOptions(), + reporter, + ctrlC, + TestRunCancellationReason.None); reporter.TestExecutionCompleted(DateTimeOffset.UtcNow, TestExitCode.Success); console.GetOutput().Should().Contain( @@ -424,7 +487,11 @@ public async Task ExecuteAsync_WhenCancelledBeforeStarting_RunsNoJobs() try { - await manager.ExecuteAsync(CreateBuildOptions(resultsDirectory), reporter, ctrlC); + await manager.ExecuteAsync( + CreateBuildOptions(resultsDirectory), + reporter, + ctrlC, + TestRunCancellationReason.None); Directory.Exists(resultsDirectory).Should().BeFalse( "a cancelled run must not start the jobs it planned"); @@ -449,7 +516,11 @@ public async Task ExecuteAsync_WebAssemblyModule_SkipsUnsupportedPostProcessing( "second.trx"); using var ctrlC = CreateCancellationManager(); - await manager.ExecuteAsync(CreateBuildOptions(), reporter, ctrlC); + await manager.ExecuteAsync( + CreateBuildOptions(), + reporter, + ctrlC, + TestRunCancellationReason.None); console.GetOutput().Should().NotContain(CliCommandStrings.ArtifactPostProcessingStarted); } @@ -558,6 +629,8 @@ private static ArtifactPostProcessingApplication CreateApplication() "net10.0", "x64", new HashSet(StringComparer.Ordinal) { "microsoft.testing.trx", "microsoft.codecoverage" }, + new HashSet(StringComparer.Ordinal), + new HashSet(StringComparer.Ordinal), new HashSet(StringComparer.Ordinal)); } @@ -572,6 +645,16 @@ private static TestModule CreateModule(string runtimeIdentifier = "") DotnetRootArchVariableName: null, EnvironmentVariables: new Dictionary()); - private static ArtifactPostProcessingArtifact CreateArtifact(string path, string? kind) - => new(path, kind, "A.dll", "net10.0", "x64", Guid.NewGuid().ToString("N")); + private static ArtifactPostProcessingArtifact CreateArtifact( + string path, + string? kind, + IReadOnlyList? inputArtifactPaths = null) + => new( + path, + kind, + "A.dll", + "net10.0", + "x64", + Guid.NewGuid().ToString("N"), + inputArtifactPaths); } diff --git a/test/dotnet.Tests/CommandTests/Test/ArtifactPostProcessingPlannerTests.cs b/test/dotnet.Tests/CommandTests/Test/ArtifactPostProcessingPlannerTests.cs index a420a68d07b4..b04186713b7c 100644 --- a/test/dotnet.Tests/CommandTests/Test/ArtifactPostProcessingPlannerTests.cs +++ b/test/dotnet.Tests/CommandTests/Test/ArtifactPostProcessingPlannerTests.cs @@ -262,12 +262,74 @@ public void Plan_TaggedArtifact_IsNotAlsoRoutedThroughTheExtensionFallback() plannedPaths.Should().BeEquivalentTo("A.trx", "B.trx", "C.trx"); } + [TestMethod] + [DataRow((int)TestRunCancellationReason.MaximumFailedTests)] + [DataRow((int)TestRunCancellationReason.Timeout)] + public void Plan_PolicyTruncatedRun_OnlyIncludesOptedInKinds(int cancellationReason) + { + ArtifactPostProcessingApplication application = CreateApplication( + "A.dll", + "net10.0", + "x64", + ["microsoft.testing.trx", "example.summary"], + [], + truncatedRunKinds: ["example.summary"]); + ArtifactPostProcessingArtifact[] artifacts = + [ + CreateArtifact("A.trx", "microsoft.testing.trx", "A.dll", "x64"), + CreateArtifact("B.trx", "microsoft.testing.trx", "B.dll", "x64"), + CreateArtifact("A.summary", "example.summary", "A.dll", "x64"), + CreateArtifact("B.summary", "example.summary", "B.dll", "x64"), + ]; + + ArtifactPostProcessingPlan plan = ArtifactPostProcessingPlanner.Plan( + [application], + artifacts, + (TestRunCancellationReason)cancellationReason); + + plan.Jobs.Should().ContainSingle(); + plan.Jobs[0].Groups.Should().ContainSingle(); + plan.Jobs[0].Groups[0].Key.Should().Be("example.summary"); + } + + [TestMethod] + [DataRow((int)TestRunCancellationReason.MaximumFailedTests)] + [DataRow((int)TestRunCancellationReason.Timeout)] + public void Plan_PolicyTruncatedRun_OnlyIncludesOptedInExtensions(int cancellationReason) + { + ArtifactPostProcessingApplication application = CreateApplication( + "A.dll", + "net10.0", + "x64", + [], + [".trx", ".summary"], + truncatedRunExtensions: [".summary"]); + ArtifactPostProcessingArtifact[] artifacts = + [ + CreateArtifact("A.trx", kind: null, "A.dll", "x64"), + CreateArtifact("B.trx", kind: null, "B.dll", "x64"), + CreateArtifact("A.summary", kind: null, "A.dll", "x64"), + CreateArtifact("B.summary", kind: null, "B.dll", "x64"), + ]; + + ArtifactPostProcessingPlan plan = ArtifactPostProcessingPlanner.Plan( + [application], + artifacts, + (TestRunCancellationReason)cancellationReason); + + plan.Jobs.Should().ContainSingle(); + plan.Jobs[0].Groups.Should().ContainSingle(); + plan.Jobs[0].Groups[0].Key.Should().Be(".summary"); + } + private static ArtifactPostProcessingApplication CreateApplication( string targetPath, string targetFramework, string architecture, string[] kinds, - string[] extensions) + string[] extensions, + string[]? truncatedRunKinds = null, + string[]? truncatedRunExtensions = null) => new( new TestModule( new RunProperties("dotnet", targetPath, null), @@ -281,7 +343,9 @@ private static ArtifactPostProcessingApplication CreateApplication( targetFramework, architecture, new HashSet(kinds, StringComparer.Ordinal), - new HashSet(extensions, StringComparer.Ordinal)); + new HashSet(extensions, StringComparer.Ordinal), + new HashSet(truncatedRunKinds ?? [], StringComparer.Ordinal), + new HashSet(truncatedRunExtensions ?? [], StringComparer.Ordinal)); private static ArtifactPostProcessingArtifact CreateArtifact( string path, diff --git a/test/dotnet.Tests/CommandTests/Test/ArtifactPostProcessingTelemetryTests.cs b/test/dotnet.Tests/CommandTests/Test/ArtifactPostProcessingTelemetryTests.cs index 19de3d4afada..7f41c886d57a 100644 --- a/test/dotnet.Tests/CommandTests/Test/ArtifactPostProcessingTelemetryTests.cs +++ b/test/dotnet.Tests/CommandTests/Test/ArtifactPostProcessingTelemetryTests.cs @@ -127,5 +127,7 @@ private static ArtifactPostProcessingApplication CreateApplication() "net10.0", "x64", new HashSet(StringComparer.Ordinal) { "microsoft.testing.trx" }, + new HashSet(StringComparer.Ordinal), + new HashSet(StringComparer.Ordinal), new HashSet(StringComparer.Ordinal)); } diff --git a/test/dotnet.Tests/CommandTests/Test/FileArtifactMessagesSerializerTests.cs b/test/dotnet.Tests/CommandTests/Test/FileArtifactMessagesSerializerTests.cs index dd2c97f9ba96..21f65ac66cf9 100644 --- a/test/dotnet.Tests/CommandTests/Test/FileArtifactMessagesSerializerTests.cs +++ b/test/dotnet.Tests/CommandTests/Test/FileArtifactMessagesSerializerTests.cs @@ -11,7 +11,7 @@ namespace dotnet.Tests.CommandTests.Test; public class FileArtifactMessagesSerializerTests { [TestMethod] - public void RoundTrip_PreservesArtifactKind() + public void RoundTrip_PreservesArtifactKindAndInputProvenance() { var original = new FileArtifactMessages( ExecutionId: "exec-1", @@ -25,7 +25,8 @@ public void RoundTrip_PreservesArtifactKind() TestUid: null, TestDisplayName: null, SessionUid: "session-1", - Kind: "trx"), + Kind: "trx", + InputArtifactPaths: ["/repo/TestResults/first.trx", "/repo/TestResults/second.trx"]), ]); var serializer = new FileArtifactMessagesSerializer(); @@ -37,5 +38,7 @@ public void RoundTrip_PreservesArtifactKind() roundTripped.FileArtifacts.Should().ContainSingle(); roundTripped.FileArtifacts[0].Kind.Should().Be("trx"); + roundTripped.FileArtifacts[0].InputArtifactPaths.Should() + .Equal("/repo/TestResults/first.trx", "/repo/TestResults/second.trx"); } } diff --git a/test/dotnet.Tests/CommandTests/Test/GivenDotnetTestBuildsAndRunsArtifactPostProcessingMTP.cs b/test/dotnet.Tests/CommandTests/Test/GivenDotnetTestBuildsAndRunsArtifactPostProcessingMTP.cs index 5456e775d676..e9d5fca76fb2 100644 --- a/test/dotnet.Tests/CommandTests/Test/GivenDotnetTestBuildsAndRunsArtifactPostProcessingMTP.cs +++ b/test/dotnet.Tests/CommandTests/Test/GivenDotnetTestBuildsAndRunsArtifactPostProcessingMTP.cs @@ -94,7 +94,7 @@ public void SingleTestApplication_ProducesOneReport_WithNoMergedReport() } [TestMethod] - public void RunCutShortByMaximumFailedTests_KeepsOneReportPerTestApplication() + public void RunCutShortByMaximumFailedTests_DoesNotRunIneligibleTrxProcessor() { TestAsset testInstance = TestAssetsManager .CopyTestAsset("MultiTestProjectSolutionWithTests", Guid.NewGuid().ToString()) @@ -115,15 +115,15 @@ public void RunCutShortByMaximumFailedTests_KeepsOneReportPerTestApplication() : []; trxReports.Should().NotContain( path => Path.GetFileName(path).StartsWith("merged-", StringComparison.Ordinal), - "a run truncated by --maximum-failed-tests skips post-processing so the truncation is not hidden behind one merged report"); + "the TRX processor does not opt into policy-truncated runs"); // The progress line is printed as soon as post-processing has anything planned, so its absence - // shows the merge was skipped rather than merely finding nothing to do. (For the same - // timing reason as above this cannot prove a plan would have existed, so it is a guard against - // the skip regressing, not a proof that a merge was averted.) + // shows no eligible group was planned. (For the same timing reason as above this cannot prove + // that two reports existed, so it is a guard against ineligible TRX processing rather than a + // proof that a merge was averted.) result.StdOut.Should().NotContain( CliCommandStrings.ArtifactPostProcessingStarted, - "a truncated run must not even start post-processing"); + "a processor that did not opt in must not run for a truncated test run"); } [TestMethod] diff --git a/test/dotnet.Tests/CommandTests/Test/TestApplicationHandlerTests.cs b/test/dotnet.Tests/CommandTests/Test/TestApplicationHandlerTests.cs index 3fe5c04587e5..6919067eee42 100644 --- a/test/dotnet.Tests/CommandTests/Test/TestApplicationHandlerTests.cs +++ b/test/dotnet.Tests/CommandTests/Test/TestApplicationHandlerTests.cs @@ -168,7 +168,9 @@ public void OnHandshakeReceived_WithArtifactPostProcessingCapabilities_RecordsAp var handshake = BuildHandshake( executionMode: HandshakeMessageExecutionModes.Run, supportedPostProcessorKinds: "microsoft.testing.trx;example.junit", - supportedPostProcessorExtensions: ".trx;.xml"); + supportedPostProcessorExtensions: ".trx;.xml", + supportedTruncatedRunPostProcessorKinds: "example.junit", + supportedTruncatedRunPostProcessorExtensions: ".xml"); bool accepted = handler.OnHandshakeReceived(handshake, gotSupportedVersion: true); @@ -176,6 +178,8 @@ public void OnHandshakeReceived_WithArtifactPostProcessingCapabilities_RecordsAp ArtifactPostProcessingApplication application = manager.SnapshotApplications().Should().ContainSingle().Subject; application.SupportedKinds.Should().BeEquivalentTo("microsoft.testing.trx", "example.junit"); application.SupportedExtensions.Should().BeEquivalentTo(".trx", ".xml"); + application.SupportedTruncatedRunKinds.Should().BeEquivalentTo("example.junit"); + application.SupportedTruncatedRunExtensions.Should().BeEquivalentTo(".xml"); } [TestMethod] @@ -205,6 +209,32 @@ public void OnFileArtifactsReceived_RecordsArtifactMetadata() artifact.ExecutionId.Should().Be("exec-1"); } + [TestMethod] + public void OnFileArtifactsReceived_FromPostProcessor_PreservesInputProvenance() + { + var invocation = new ArtifactPostProcessingInvocation("manifest.json"); + (TestApplicationHandler handler, _, _) = CreateHandler( + isHelp: false, + isDiscovery: false, + artifactPostProcessingInvocation: invocation); + handler.OnHandshakeReceived( + BuildHandshake( + HandshakeMessageExecutionModes.Tool, + hostType: HandshakeMessageHostTypes.ArtifactPostProcessor), + gotSupportedVersion: true).Should().BeTrue(); + string outputPath = Path.GetFullPath("merged.summary"); + string[] inputPaths = [Path.GetFullPath("first.summary"), Path.GetFullPath("second.summary")]; + + handler.OnFileArtifactsReceived(new FileArtifactMessages( + "exec-1", + "inst-1", + [new FileArtifactMessage(outputPath, "Summary", null, null, null, null, "example.summary", inputPaths)])); + + ArtifactPostProcessingArtifact artifact = invocation.SnapshotOutputs().Should().ContainSingle().Subject; + artifact.Path.Should().Be(outputPath); + artifact.InputArtifactPaths.Should().Equal(inputPaths); + } + [TestMethod] public void OnHandshakeReceived_WhenArtifactPostProcessorHandshakeFails_DoesNotFailTestRun() { @@ -547,7 +577,9 @@ private static HandshakeMessage BuildHandshake( bool includeInstanceId = true, int? attemptNumber = null, string? supportedPostProcessorKinds = null, - string? supportedPostProcessorExtensions = null) + string? supportedPostProcessorExtensions = null, + string? supportedTruncatedRunPostProcessorKinds = null, + string? supportedTruncatedRunPostProcessorExtensions = null) { var properties = new Dictionary { @@ -586,6 +618,18 @@ private static HandshakeMessage BuildHandshake( properties[HandshakeMessagePropertyNames.SupportedPostProcessorExtensionsLegacy] = supportedPostProcessorExtensions; } + if (supportedTruncatedRunPostProcessorKinds is not null) + { + properties[HandshakeMessagePropertyNames.SupportedTruncatedRunPostProcessorKinds] = + supportedTruncatedRunPostProcessorKinds; + } + + if (supportedTruncatedRunPostProcessorExtensions is not null) + { + properties[HandshakeMessagePropertyNames.SupportedTruncatedRunPostProcessorExtensionsLegacy] = + supportedTruncatedRunPostProcessorExtensions; + } + return new HandshakeMessage(properties); } }