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);
}
}