diff --git a/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportGenerator.cs b/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportGenerator.cs
index d29ea1056b..d03025d55f 100644
--- a/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportGenerator.cs
+++ b/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportGenerator.cs
@@ -24,6 +24,8 @@ public CtrfReportGenerator(IServiceProvider serviceProvider)
protected override string ArtifactDisplayName => ExtensionResources.CtrfReportArtifactDisplayName;
+ protected override string? ArtifactKind => "microsoft.testing.ctrf";
+
protected override string ArtifactDescription => ExtensionResources.CtrfReportArtifactDescription;
protected override string GetGenerationLogMessage(int testResultCount)
diff --git a/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs b/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs
new file mode 100644
index 0000000000..e38a870766
--- /dev/null
+++ b/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs
@@ -0,0 +1,418 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+using Microsoft.Testing.Platform;
+
+namespace Microsoft.Testing.Extensions.CtrfReport;
+
+///
+/// Merges several already-produced CTRF JSON reports into a single CTRF document.
+///
+///
+/// This is a pure, invocation-agnostic JSON-level merge (no I/O, no clock) that mirrors the
+/// TRX and JUnit mergers, demonstrating that the same post-processing shape fits a JSON format:
+///
+/// - results.tests[] arrays are concatenated as-is.
+/// - results.summary counters are re-derived by counting the merged tests[] (so summary.tests always matches the array length); start/stop use the earliest/latest across inputs, duration is the resulting span.
+/// - reportFormat and specVersion are taken from the first report; reportId is derived deterministically from the inputs, so identical inputs reproduce the same id (RFC 018 idempotency).
+/// - tool keeps a concrete identity only when every input reported the exact same tool object; otherwise (inputs disagree or any input omits it) a neutral merger identity is used, so one framework is not attributed to another's tests.
+/// - environment keeps the first report's shared fields, but module-specific values under extra (testApplication, exitCode) are dropped rather than presented as describing all merged modules.
+///
+///
+internal static class CtrfReportMerger
+{
+ // Neutral tool identity used when merged inputs disagree on their producing test framework, so the
+ // merged report does not misattribute one framework's identity to another's tests.
+ private const string MergedToolName = "Microsoft.Testing.Extensions.CtrfReport (merged)";
+
+ // Identity stamped into the merged document's 'generatedBy' — the merge is produced by this extension,
+ // not by any input report.
+ private const string GeneratedByName = "Microsoft.Testing.Extensions.CtrfReport";
+
+ internal static string Merge(IReadOnlyList inputReports)
+ {
+ if (inputReports is null)
+ {
+ throw new ArgumentNullException(nameof(inputReports));
+ }
+
+ if (inputReports.Count == 0)
+ {
+ throw new ArgumentException("At least one CTRF report is required to merge.", nameof(inputReports));
+ }
+
+ JsonObject? first = null;
+ var mergedTests = new JsonArray();
+
+ // Accumulate the raw JSON of every ACCEPTED CTRF input so the deterministic reportId is derived only
+ // from the payloads that actually contributed to the merge. Hashing the unfiltered inputs would let a
+ // rejected non-CTRF input (which is skipped below) change the merged report's identity.
+ var acceptedReports = new List(inputReports.Count);
+
+ long? earliestStart = null;
+ long? latestStop = null;
+
+ // A same-kind merge can combine modules produced by different test frameworks. Track the
+ // distinct *complete* tool identities (full serialized tool object, not just its name) so the
+ // merged report is only stamped with a concrete framework when every input reported the exact
+ // same tool; otherwise a neutral merger identity is used (see below). An input that omits 'tool'
+ // counts as a distinct (missing) identity, so a mix of tagged/untagged inputs also degrades.
+ var distinctToolIdentities = new HashSet(StringComparer.Ordinal);
+ JsonNode? firstTool = null;
+ int reportCount = 0;
+
+ // Collect each input's environment so shared fields can be retained and module- or agent-specific
+ // ones (values that differ across inputs) dropped, rather than attributing the first report's
+ // environment to every merged test.
+ var environments = new List();
+
+ foreach (string reportJson in inputReports)
+ {
+ if (JsonNode.Parse(reportJson) is not JsonObject root)
+ {
+ continue;
+ }
+
+ // Only accept genuine CTRF documents: reportFormat is the required format discriminator, so an
+ // object without it (or with a non-CTRF value), or with no results object, must not become
+ // 'first' and have CTRF-shaped data emitted under its label.
+ if (root["results"] is not JsonObject)
+ {
+ continue;
+ }
+
+ string? format = root["reportFormat"] is JsonValue formatValue && formatValue.TryGetValue(out string? formatText) ? formatText : null;
+ if (!string.Equals(format, "CTRF", StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ first ??= root;
+ reportCount++;
+ acceptedReports.Add(reportJson);
+
+ if (root["results"]?["environment"] is JsonObject environment)
+ {
+ environments.Add(environment);
+ }
+
+ JsonNode? results = root["results"];
+ if (results?["tests"] is JsonArray testArray)
+ {
+ foreach (JsonNode? test in testArray)
+ {
+ mergedTests.Add(test?.DeepClone());
+
+ // Fall back to per-test timing so a summary-less input (which the merger explicitly
+ // supports) still contributes to the merged min/max instead of being dropped or
+ // forcing the merged timestamp back to the Unix epoch.
+ if (test is not null)
+ {
+ if (TryReadLong(test, "start", out long testStart))
+ {
+ earliestStart = Min(earliestStart, testStart);
+ }
+
+ if (TryReadLong(test, "stop", out long testStop))
+ {
+ latestStop = Max(latestStop, testStop);
+ }
+ }
+ }
+ }
+
+ if (results?["tool"] is JsonNode toolNode)
+ {
+ firstTool ??= toolNode;
+ distinctToolIdentities.Add(toolNode.ToJsonString());
+ }
+ else
+ {
+ distinctToolIdentities.Add(string.Empty);
+ }
+
+ JsonNode? summary = results?["summary"];
+ if (summary is not null)
+ {
+ if (TryReadLong(summary, "start", out long start))
+ {
+ earliestStart = Min(earliestStart, start);
+ }
+
+ if (TryReadLong(summary, "stop", out long stop))
+ {
+ latestStop = Max(latestStop, stop);
+ }
+ }
+ }
+
+ if (first is null)
+ {
+ throw new ArgumentException("None of the provided inputs were valid CTRF reports.", nameof(inputReports));
+ }
+
+ long startMs = earliestStart ?? 0;
+ long stopMs = latestStop ?? startMs;
+
+ // Counters are derived from the merged tests[] rather than trusting each input's summary, so
+ // summary.tests always equals the array length even when an input omitted or under-reported
+ // its summary.
+ long passed = 0, failed = 0, skipped = 0, pending = 0, other = 0, flaky = 0;
+ foreach (JsonNode? test in mergedTests)
+ {
+ if (test is null)
+ {
+ continue;
+ }
+
+ switch ((string?)test["status"])
+ {
+ case "passed": passed++; break;
+ case "failed": failed++; break;
+ case "skipped": skipped++; break;
+ case "pending": pending++; break;
+ default: other++; break;
+ }
+
+ if (test["flaky"] is JsonValue flakyValue && flakyValue.TryGetValue(out bool isFlaky) && isFlaky)
+ {
+ flaky++;
+ }
+ }
+
+ var summaryObject = new JsonObject
+ {
+ ["tests"] = mergedTests.Count,
+ ["passed"] = passed,
+ ["failed"] = failed,
+ ["skipped"] = skipped,
+ ["pending"] = pending,
+ ["other"] = other,
+ ["flaky"] = flaky,
+ ["start"] = startMs,
+ ["stop"] = stopMs,
+ ["duration"] = Math.Max(0, stopMs - startMs),
+ };
+
+ var resultsObject = new JsonObject();
+
+ // Only carry a concrete tool identity when every input reported the exact same one (the common
+ // single-framework case). When inputs disagree — different tool objects, or a mix of tagged and
+ // untagged inputs — stamping the first framework onto all tests would misattribute the others,
+ // so use a neutral merger identity instead.
+ bool allInputsShareTool = distinctToolIdentities.Count == 1 && firstTool is not null && reportCount > 0;
+ resultsObject["tool"] = allInputsShareTool
+ ? firstTool!.DeepClone()
+ : new JsonObject { ["name"] = MergedToolName };
+
+ resultsObject["summary"] = summaryObject;
+
+ // Retain only environment fields that every input agrees on: OS/user/machine are shared when the
+ // merge is same-machine, but invocation-agnostic inputs can come from different CI agents, so a
+ // differing value would misstate the environment for most tests. A report that supplies no
+ // environment at all counts as disagreement (its fields are absent), so a common field requires
+ // every accepted report to provide it. Module-specific 'extra' values (the producing test
+ // application and its exit code) are always dropped.
+ if (BuildCommonEnvironment(environments, reportCount) is JsonObject commonEnvironment)
+ {
+ resultsObject["environment"] = commonEnvironment;
+ }
+
+ resultsObject["tests"] = mergedTests;
+
+ var merged = new JsonObject
+ {
+ ["reportFormat"] = first["reportFormat"]?.DeepClone() ?? "CTRF",
+ ["specVersion"] = first["specVersion"]?.DeepClone() ?? "0.0.0",
+ ["reportId"] = CreateDeterministicReportId(acceptedReports),
+ ["timestamp"] = DateTimeOffset.FromUnixTimeMilliseconds(stopMs).ToString("O", CultureInfo.InvariantCulture),
+ // The merged document is produced by this merger, not by any input, so stamp its own identity
+ // rather than carrying the first input's 'generatedBy' (which could report a different producer
+ // or version when merging reports from different tool versions).
+ ["generatedBy"] = GeneratedByName,
+ ["results"] = resultsObject,
+ };
+
+ return merged.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
+ }
+
+ internal static async Task MergeToFileAsync(
+ IReadOnlyList inputPaths,
+ string outputPath,
+ CancellationToken cancellationToken)
+ {
+ if (inputPaths is null)
+ {
+ throw new ArgumentNullException(nameof(inputPaths));
+ }
+
+ if (outputPath is null)
+ {
+ throw new ArgumentNullException(nameof(outputPath));
+ }
+
+ // Reject an empty input list before any filesystem work (Merge throws for empty input, but only
+ // after the output directory would already have been created).
+ if (inputPaths.Count == 0)
+ {
+ throw new ArgumentException("At least one CTRF report is required to merge.", nameof(inputPaths));
+ }
+
+ // RFC 018 treats per-module inputs as read-only and requires them to remain on disk; reject an
+ // output that aliases an input so a merge can never overwrite one of its own sources.
+ MergeOutputFileHelper.EnsureOutputDoesNotAliasInput(inputPaths, outputPath);
+
+ var reports = new List(inputPaths.Count);
+ foreach (string inputPath in inputPaths)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+#if NETCOREAPP
+ reports.Add(await File.ReadAllTextAsync(inputPath, cancellationToken).ConfigureAwait(false));
+#else
+ reports.Add(File.ReadAllText(inputPath));
+#endif
+ }
+
+ string merged = Merge(reports);
+
+ string? outputDirectory = Path.GetDirectoryName(outputPath);
+ if (!RoslynString.IsNullOrEmpty(outputDirectory))
+ {
+ Directory.CreateDirectory(outputDirectory);
+ }
+
+ // Write to a temporary sibling, then replace the destination ENTRY, so a symlink/hardlink output
+ // alias of an input has only its link removed rather than the read-only source truncated in place.
+ await MergeOutputFileHelper.WriteViaTemporarySiblingAsync(outputPath, async tempPath =>
+ {
+#if NETCOREAPP
+ await File.WriteAllTextAsync(tempPath, merged, cancellationToken).ConfigureAwait(false);
+#else
+ File.WriteAllText(tempPath, merged);
+ await Task.CompletedTask.ConfigureAwait(false);
+#endif
+ }).ConfigureAwait(false);
+ }
+
+ private static bool TryReadLong(JsonNode summary, string propertyName, out long value)
+ {
+ value = 0;
+ if (summary[propertyName] is not JsonValue jsonValue)
+ {
+ return false;
+ }
+
+ if (jsonValue.TryGetValue(out long longValue))
+ {
+ value = longValue;
+ return true;
+ }
+
+ if (jsonValue.TryGetValue(out double doubleValue))
+ {
+ value = (long)doubleValue;
+ return true;
+ }
+
+ return false;
+ }
+
+ ///
+ /// Builds a merged environment containing only the fields that every input's environment agrees on
+ /// (so a value that differs across CI agents is dropped rather than attributed to all tests). The
+ /// module-specific extra.testApplication and extra.exitCode fields are always dropped.
+ /// Returns when no environment survives.
+ ///
+ private static JsonObject? BuildCommonEnvironment(IReadOnlyList environments, int reportCount)
+ {
+ // A field can only be common to all inputs if every accepted report supplied an environment; a
+ // report with no environment is a disagreement (the field is absent there).
+ if (environments.Count == 0 || environments.Count != reportCount)
+ {
+ return null;
+ }
+
+ var merged = new JsonObject();
+ foreach (KeyValuePair field in environments[0])
+ {
+ if (field.Key == "extra")
+ {
+ continue;
+ }
+
+ string firstValue = field.Value?.ToJsonString() ?? "null";
+ if (environments.All(e => (e[field.Key]?.ToJsonString() ?? "null") == firstValue))
+ {
+ merged[field.Key] = field.Value?.DeepClone();
+ }
+ }
+
+ if (environments[0]["extra"] is JsonObject firstExtra)
+ {
+ var extra = new JsonObject();
+ foreach (KeyValuePair field in firstExtra)
+ {
+ if (field.Key is "testApplication" or "exitCode")
+ {
+ continue;
+ }
+
+ string firstValue = field.Value?.ToJsonString() ?? "null";
+ if (environments.All(e => e["extra"] is JsonObject extraObject && (extraObject[field.Key]?.ToJsonString() ?? "null") == firstValue))
+ {
+ extra[field.Key] = field.Value?.DeepClone();
+ }
+ }
+
+ if (extra.Count > 0)
+ {
+ merged["extra"] = extra;
+ }
+ }
+
+ return merged.Count > 0 ? merged : null;
+ }
+
+ ///
+ /// Derives a stable reportId from the accepted CTRF input reports so identical inputs reproduce
+ /// the same id on every retry (RFC 018 idempotency) without a random source or reusing an input report's
+ /// id. Only the payloads that passed CTRF validation are hashed, so a rejected non-CTRF input cannot
+ /// alter the merged report's identity. A non-cryptographic 128-bit FNV-1a fill is sufficient here — the
+ /// id only needs to be deterministic and collision-resistant enough to identify a merged report, not
+ /// secret.
+ ///
+ private static string CreateDeterministicReportId(IReadOnlyList acceptedReports)
+ {
+ const ulong fnvPrime = 1099511628211UL;
+ ulong hashLow = 14695981039346656037UL;
+ ulong hashHigh = 0x9E3779B97F4A7C15UL;
+
+ foreach (string report in acceptedReports)
+ {
+ foreach (char c in report)
+ {
+ hashLow = (hashLow ^ c) * fnvPrime;
+ hashHigh = (hashHigh ^ c) * fnvPrime;
+ }
+
+ // Fold in each input's length so different chunk boundaries (e.g. ["ab","c"] vs ["a","bc"])
+ // never collide.
+ hashLow = (hashLow ^ (ulong)report.Length) * fnvPrime;
+ hashHigh = (hashHigh ^ ((ulong)report.Length + 1UL)) * fnvPrime;
+ }
+
+ byte[] bytes = new byte[16];
+ BitConverter.GetBytes(hashLow).CopyTo(bytes, 0);
+ BitConverter.GetBytes(hashHigh).CopyTo(bytes, 8);
+ return new Guid(bytes).ToString("D");
+ }
+
+ private static long Min(long? current, long candidate)
+ => current is null || candidate < current ? candidate : current.Value;
+
+ private static long Max(long? current, long candidate)
+ => current is null || candidate > current ? candidate : current.Value;
+}
diff --git a/src/Platform/Microsoft.Testing.Extensions.CtrfReport/InternalAPI/InternalAPI.Unshipped.txt b/src/Platform/Microsoft.Testing.Extensions.CtrfReport/InternalAPI/InternalAPI.Unshipped.txt
index 648404fc05..47b0d80c90 100644
--- a/src/Platform/Microsoft.Testing.Extensions.CtrfReport/InternalAPI/InternalAPI.Unshipped.txt
+++ b/src/Platform/Microsoft.Testing.Extensions.CtrfReport/InternalAPI/InternalAPI.Unshipped.txt
@@ -1,4 +1,11 @@
#nullable enable
+virtual Microsoft.Testing.Extensions.ReportGeneratorBase.ArtifactKind.get -> string?
+Microsoft.Testing.Extensions.CtrfReport.CtrfReportMerger
+static Microsoft.Testing.Extensions.CtrfReport.CtrfReportMerger.Merge(System.Collections.Generic.IReadOnlyList! inputReports) -> string!
+static Microsoft.Testing.Extensions.CtrfReport.CtrfReportMerger.MergeToFileAsync(System.Collections.Generic.IReadOnlyList! inputPaths, string! outputPath, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task!
static Microsoft.Testing.Platform.Resources.PlatformResources.NamedPipeDirectoryNotWritableErrorMessage.get -> string!
static Microsoft.Testing.Platform.Resources.PlatformResources.NamedPipePathTooLongErrorMessage.get -> string!
static Microsoft.Testing.Platform.Services.ArtifactNamingHelper.ResolveAndSanitize(string! template, string! processName, string! processId, System.DateTimeOffset timestamp, System.Func! sanitizeLeafFileName) -> string!
+Microsoft.Testing.Extensions.MergeOutputFileHelper
+static Microsoft.Testing.Extensions.MergeOutputFileHelper.EnsureOutputDoesNotAliasInput(System.Collections.Generic.IReadOnlyList! inputPaths, string! outputPath) -> void
+static Microsoft.Testing.Extensions.MergeOutputFileHelper.WriteViaTemporarySiblingAsync(string! outputPath, System.Func! writeToTempAsync) -> System.Threading.Tasks.Task!
diff --git a/src/Platform/Microsoft.Testing.Extensions.CtrfReport/Microsoft.Testing.Extensions.CtrfReport.csproj b/src/Platform/Microsoft.Testing.Extensions.CtrfReport/Microsoft.Testing.Extensions.CtrfReport.csproj
index 65a16472f6..a9f7c6dccd 100644
--- a/src/Platform/Microsoft.Testing.Extensions.CtrfReport/Microsoft.Testing.Extensions.CtrfReport.csproj
+++ b/src/Platform/Microsoft.Testing.Extensions.CtrfReport/Microsoft.Testing.Extensions.CtrfReport.csproj
@@ -63,6 +63,7 @@ This package extends Microsoft Testing Platform to produce test reports in the C
+
diff --git a/src/Platform/Microsoft.Testing.Extensions.HangDump/InternalAPI/InternalAPI.Unshipped.txt b/src/Platform/Microsoft.Testing.Extensions.HangDump/InternalAPI/InternalAPI.Unshipped.txt
index e541f7a149..4351ff8ae6 100644
--- a/src/Platform/Microsoft.Testing.Extensions.HangDump/InternalAPI/InternalAPI.Unshipped.txt
+++ b/src/Platform/Microsoft.Testing.Extensions.HangDump/InternalAPI/InternalAPI.Unshipped.txt
@@ -1,4 +1,5 @@
#nullable enable
+const Microsoft.Testing.Platform.IPC.FileArtifactMessageFieldsId.Kind = 7 -> ushort
const Microsoft.Testing.Platform.Helpers.EnvironmentVariableConstants.TESTINGPLATFORM_PIPE_DIRECTORY = "TESTINGPLATFORM_PIPE_DIRECTORY" -> string!
const Microsoft.Testing.Platform.IPC.NamedPipeServer.MaxUnixDomainSocketPathLengthInBytes = 103 -> int
static Microsoft.Testing.Platform.IPC.NamedPipeServer.EnsureDirectoryIsWritable(string! directory) -> void
diff --git a/src/Platform/Microsoft.Testing.Extensions.HtmlReport/HtmlReportGenerator.cs b/src/Platform/Microsoft.Testing.Extensions.HtmlReport/HtmlReportGenerator.cs
index 3d08ec79d9..1debfa387f 100644
--- a/src/Platform/Microsoft.Testing.Extensions.HtmlReport/HtmlReportGenerator.cs
+++ b/src/Platform/Microsoft.Testing.Extensions.HtmlReport/HtmlReportGenerator.cs
@@ -24,6 +24,8 @@ public HtmlReportGenerator(IServiceProvider serviceProvider)
protected override string ArtifactDisplayName => ExtensionResources.HtmlReportArtifactDisplayName;
+ protected override string? ArtifactKind => "microsoft.testing.html";
+
protected override string ArtifactDescription => ExtensionResources.HtmlReportArtifactDescription;
protected override string GetGenerationLogMessage(int testResultCount)
diff --git a/src/Platform/Microsoft.Testing.Extensions.HtmlReport/InternalAPI/InternalAPI.Unshipped.txt b/src/Platform/Microsoft.Testing.Extensions.HtmlReport/InternalAPI/InternalAPI.Unshipped.txt
index 648404fc05..af37c04771 100644
--- a/src/Platform/Microsoft.Testing.Extensions.HtmlReport/InternalAPI/InternalAPI.Unshipped.txt
+++ b/src/Platform/Microsoft.Testing.Extensions.HtmlReport/InternalAPI/InternalAPI.Unshipped.txt
@@ -1,4 +1,5 @@
#nullable enable
+virtual Microsoft.Testing.Extensions.ReportGeneratorBase.ArtifactKind.get -> string?
static Microsoft.Testing.Platform.Resources.PlatformResources.NamedPipeDirectoryNotWritableErrorMessage.get -> string!
static Microsoft.Testing.Platform.Resources.PlatformResources.NamedPipePathTooLongErrorMessage.get -> string!
static Microsoft.Testing.Platform.Services.ArtifactNamingHelper.ResolveAndSanitize(string! template, string! processName, string! processId, System.DateTimeOffset timestamp, System.Func! sanitizeLeafFileName) -> string!
diff --git a/src/Platform/Microsoft.Testing.Extensions.JUnitReport/InternalAPI/InternalAPI.Unshipped.txt b/src/Platform/Microsoft.Testing.Extensions.JUnitReport/InternalAPI/InternalAPI.Unshipped.txt
index 648404fc05..1798ddc8b0 100644
--- a/src/Platform/Microsoft.Testing.Extensions.JUnitReport/InternalAPI/InternalAPI.Unshipped.txt
+++ b/src/Platform/Microsoft.Testing.Extensions.JUnitReport/InternalAPI/InternalAPI.Unshipped.txt
@@ -1,4 +1,11 @@
#nullable enable
+virtual Microsoft.Testing.Extensions.ReportGeneratorBase.ArtifactKind.get -> string?
+Microsoft.Testing.Extensions.JUnitReport.JUnitReportMerger
+static Microsoft.Testing.Extensions.JUnitReport.JUnitReportMerger.Merge(System.Collections.Generic.IReadOnlyList! inputReports, string! reportName) -> System.Xml.Linq.XDocument!
+static Microsoft.Testing.Extensions.JUnitReport.JUnitReportMerger.MergeToFileAsync(System.Collections.Generic.IReadOnlyList! inputPaths, string! outputPath, string! reportName, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task!
static Microsoft.Testing.Platform.Resources.PlatformResources.NamedPipeDirectoryNotWritableErrorMessage.get -> string!
static Microsoft.Testing.Platform.Resources.PlatformResources.NamedPipePathTooLongErrorMessage.get -> string!
static Microsoft.Testing.Platform.Services.ArtifactNamingHelper.ResolveAndSanitize(string! template, string! processName, string! processId, System.DateTimeOffset timestamp, System.Func! sanitizeLeafFileName) -> string!
+Microsoft.Testing.Extensions.MergeOutputFileHelper
+static Microsoft.Testing.Extensions.MergeOutputFileHelper.EnsureOutputDoesNotAliasInput(System.Collections.Generic.IReadOnlyList! inputPaths, string! outputPath) -> void
+static Microsoft.Testing.Extensions.MergeOutputFileHelper.WriteViaTemporarySiblingAsync(string! outputPath, System.Func! writeToTempAsync) -> System.Threading.Tasks.Task!
diff --git a/src/Platform/Microsoft.Testing.Extensions.JUnitReport/JUnitReportGenerator.cs b/src/Platform/Microsoft.Testing.Extensions.JUnitReport/JUnitReportGenerator.cs
index 34601e8e79..c8fc0ac642 100644
--- a/src/Platform/Microsoft.Testing.Extensions.JUnitReport/JUnitReportGenerator.cs
+++ b/src/Platform/Microsoft.Testing.Extensions.JUnitReport/JUnitReportGenerator.cs
@@ -33,6 +33,8 @@ public JUnitReportGenerator(IServiceProvider serviceProvider)
protected override string ArtifactDisplayName => ExtensionResources.JUnitReportArtifactDisplayName;
+ protected override string? ArtifactKind => "microsoft.testing.junit";
+
protected override string ArtifactDescription => ExtensionResources.JUnitReportArtifactDescription;
protected override string GetGenerationLogMessage(int testResultCount)
diff --git a/src/Platform/Microsoft.Testing.Extensions.JUnitReport/JUnitReportMerger.cs b/src/Platform/Microsoft.Testing.Extensions.JUnitReport/JUnitReportMerger.cs
new file mode 100644
index 0000000000..9251aaf898
--- /dev/null
+++ b/src/Platform/Microsoft.Testing.Extensions.JUnitReport/JUnitReportMerger.cs
@@ -0,0 +1,186 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Microsoft.Testing.Platform;
+
+namespace Microsoft.Testing.Extensions.JUnitReport;
+
+///
+/// Merges several already-produced JUnit XML reports into a single JUnit document.
+///
+///
+/// This is a pure, invocation-agnostic XML-level merge (no I/O, no clock) that mirrors the
+/// approach used for TRX: a user-facing merge tool and an SDK-orchestrated post-processor can
+/// share it and, given the same inputs and reportName, produce deterministic output.
+///
+/// Merge rules:
+///
+/// - Every <testsuite> element is unioned as-is and re-assigned a sequential id. Both <testsuites>-rooted documents and bare <testsuite>-rooted documents are supported; any other root is skipped.
+/// - Root tests/failures/errors/skipped/time counters are derived by summing the per-suite counters, so they are correct even when an input's root aggregates are missing.
+/// - The root timestamp is the earliest across all merged suites.
+///
+///
+///
+internal static class JUnitReportMerger
+{
+ private const string RootElementName = "testsuites";
+ private const string SuiteElementName = "testsuite";
+
+ internal static XDocument Merge(IReadOnlyList inputReports, string reportName)
+ {
+ if (inputReports is null)
+ {
+ throw new ArgumentNullException(nameof(inputReports));
+ }
+
+ if (reportName is null)
+ {
+ throw new ArgumentNullException(nameof(reportName));
+ }
+
+ if (inputReports.Count == 0)
+ {
+ throw new ArgumentException("At least one JUnit report is required to merge.", nameof(inputReports));
+ }
+
+ long totalTests = 0;
+ long totalFailures = 0;
+ long totalErrors = 0;
+ long totalSkipped = 0;
+ double totalTime = 0;
+ DateTimeOffset? earliestTimestamp = null;
+
+ var mergedRoot = new XElement(RootElementName);
+ int suiteId = 0;
+
+ foreach (XDocument report in inputReports)
+ {
+ XElement? root = report.Root;
+ if (root is null)
+ {
+ continue;
+ }
+
+ // Support both -rooted documents and a bare root (a valid,
+ // common JUnit shape); any other root has no suites to contribute and is skipped.
+ IEnumerable suites = string.Equals(root.Name.LocalName, RootElementName, StringComparison.Ordinal)
+ ? root.Elements().Where(e => string.Equals(e.Name.LocalName, SuiteElementName, StringComparison.Ordinal))
+ : string.Equals(root.Name.LocalName, SuiteElementName, StringComparison.Ordinal)
+ ? [root]
+ : [];
+
+ foreach (XElement suite in suites)
+ {
+ var clonedSuite = new XElement(suite);
+ clonedSuite.SetAttributeValue("id", suiteId++);
+ mergedRoot.Add(clonedSuite);
+
+ // Derive aggregates from the per-suite counters rather than trusting the (optional)
+ // root aggregates, so a merge cannot silently under-count.
+ totalTests += ReadLong(suite, "tests");
+ totalFailures += ReadLong(suite, "failures");
+ totalErrors += ReadLong(suite, "errors");
+ totalSkipped += ReadLong(suite, "skipped");
+ totalTime += ReadDouble(suite, "time");
+
+ if (TryReadTimestamp(suite, "timestamp", out DateTimeOffset timestamp)
+ && (earliestTimestamp is null || timestamp < earliestTimestamp))
+ {
+ earliestTimestamp = timestamp;
+ }
+ }
+ }
+
+ mergedRoot.SetAttributeValue("name", reportName);
+ mergedRoot.SetAttributeValue("tests", totalTests.ToString(CultureInfo.InvariantCulture));
+ mergedRoot.SetAttributeValue("failures", totalFailures.ToString(CultureInfo.InvariantCulture));
+ mergedRoot.SetAttributeValue("errors", totalErrors.ToString(CultureInfo.InvariantCulture));
+ mergedRoot.SetAttributeValue("skipped", totalSkipped.ToString(CultureInfo.InvariantCulture));
+ mergedRoot.SetAttributeValue("time", totalTime.ToString("0.000", CultureInfo.InvariantCulture));
+ if (earliestTimestamp is { } stamp)
+ {
+ mergedRoot.SetAttributeValue("timestamp", stamp.UtcDateTime.ToString("yyyy-MM-ddTHH:mm:ss.fff", CultureInfo.InvariantCulture));
+ }
+
+ return new XDocument(new XDeclaration("1.0", "UTF-8", null), mergedRoot);
+ }
+
+ internal static async Task MergeToFileAsync(
+ IReadOnlyList inputPaths,
+ string outputPath,
+ string reportName,
+ CancellationToken cancellationToken)
+ {
+ if (inputPaths is null)
+ {
+ throw new ArgumentNullException(nameof(inputPaths));
+ }
+
+ if (outputPath is null)
+ {
+ throw new ArgumentNullException(nameof(outputPath));
+ }
+
+ // Reject an empty input list before any filesystem work (Merge throws for empty input, but only
+ // after the output directory would already have been created).
+ if (inputPaths.Count == 0)
+ {
+ throw new ArgumentException("At least one JUnit report is required to merge.", nameof(inputPaths));
+ }
+
+ // RFC 018 treats per-module inputs as read-only and requires them to remain on disk; reject an
+ // output that aliases an input so a merge (which writes with a truncating File.Create) can never
+ // overwrite one of its own sources.
+ MergeOutputFileHelper.EnsureOutputDoesNotAliasInput(inputPaths, outputPath);
+
+ var reports = new List(inputPaths.Count);
+ foreach (string inputPath in inputPaths)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ reports.Add(XDocument.Load(inputPath));
+ }
+
+ XDocument merged = Merge(reports, reportName);
+
+ string? outputDirectory = Path.GetDirectoryName(outputPath);
+ if (!RoslynString.IsNullOrEmpty(outputDirectory))
+ {
+ Directory.CreateDirectory(outputDirectory);
+ }
+
+ // Write to a temporary sibling, then replace the destination ENTRY, so a symlink/hardlink output
+ // alias of an input has only its link removed rather than the read-only source truncated in place.
+ await MergeOutputFileHelper.WriteViaTemporarySiblingAsync(outputPath, async tempPath =>
+ {
+ using FileStream stream = File.Create(tempPath);
+#if NETCOREAPP
+ await merged.SaveAsync(stream, SaveOptions.None, cancellationToken).ConfigureAwait(false);
+#else
+ merged.Save(stream, SaveOptions.None);
+ await Task.CompletedTask.ConfigureAwait(false);
+#endif
+ }).ConfigureAwait(false);
+ }
+
+ private static long ReadLong(XElement element, string attributeName)
+ => long.TryParse(element.Attribute(attributeName)?.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out long value)
+ ? value
+ : 0;
+
+ private static double ReadDouble(XElement element, string attributeName)
+ => double.TryParse(element.Attribute(attributeName)?.Value, NumberStyles.Float, CultureInfo.InvariantCulture, out double value)
+ ? value
+ : 0;
+
+ private static bool TryReadTimestamp(XElement element, string attributeName, out DateTimeOffset result)
+ {
+ string? value = element.Attribute(attributeName)?.Value;
+ if (RoslynString.IsNullOrEmpty(value))
+ {
+ result = default;
+ return false;
+ }
+
+ return DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out result);
+ }
+}
diff --git a/src/Platform/Microsoft.Testing.Extensions.JUnitReport/Microsoft.Testing.Extensions.JUnitReport.csproj b/src/Platform/Microsoft.Testing.Extensions.JUnitReport/Microsoft.Testing.Extensions.JUnitReport.csproj
index adb06a621f..58f63bca8c 100644
--- a/src/Platform/Microsoft.Testing.Extensions.JUnitReport/Microsoft.Testing.Extensions.JUnitReport.csproj
+++ b/src/Platform/Microsoft.Testing.Extensions.JUnitReport/Microsoft.Testing.Extensions.JUnitReport.csproj
@@ -53,6 +53,7 @@ This package extends Microsoft Testing Platform to produce JUnit XML test report
+
diff --git a/src/Platform/Microsoft.Testing.Extensions.MSBuild/InternalAPI.Unshipped.txt b/src/Platform/Microsoft.Testing.Extensions.MSBuild/InternalAPI.Unshipped.txt
index d5d961ab7a..218d038a05 100644
--- a/src/Platform/Microsoft.Testing.Extensions.MSBuild/InternalAPI.Unshipped.txt
+++ b/src/Platform/Microsoft.Testing.Extensions.MSBuild/InternalAPI.Unshipped.txt
@@ -1,4 +1,5 @@
#nullable enable
+const Microsoft.Testing.Platform.IPC.FileArtifactMessageFieldsId.Kind = 7 -> ushort
const Microsoft.Testing.Platform.Helpers.EnvironmentVariableConstants.TESTINGPLATFORM_PIPE_DIRECTORY = "TESTINGPLATFORM_PIPE_DIRECTORY" -> string!
const Microsoft.Testing.Platform.IPC.NamedPipeServer.MaxUnixDomainSocketPathLengthInBytes = 103 -> int
static Microsoft.Testing.Platform.IPC.NamedPipeServer.EnsureDirectoryIsWritable(string! directory) -> void
diff --git a/src/Platform/Microsoft.Testing.Extensions.Retry/InternalAPI/InternalAPI.Unshipped.txt b/src/Platform/Microsoft.Testing.Extensions.Retry/InternalAPI/InternalAPI.Unshipped.txt
index cb6da81801..fc263df0b4 100644
--- a/src/Platform/Microsoft.Testing.Extensions.Retry/InternalAPI/InternalAPI.Unshipped.txt
+++ b/src/Platform/Microsoft.Testing.Extensions.Retry/InternalAPI/InternalAPI.Unshipped.txt
@@ -1,4 +1,5 @@
#nullable enable
+const Microsoft.Testing.Platform.IPC.FileArtifactMessageFieldsId.Kind = 7 -> ushort
const Microsoft.Testing.Platform.Helpers.EnvironmentVariableConstants.TESTINGPLATFORM_PIPE_DIRECTORY = "TESTINGPLATFORM_PIPE_DIRECTORY" -> string!
Microsoft.Testing.Platform.Helpers.ArgumentGuard
Microsoft.Testing.Platform.Helpers.PasteArguments
diff --git a/src/Platform/Microsoft.Testing.Extensions.TrxReport/InternalAPI/InternalAPI.Unshipped.txt b/src/Platform/Microsoft.Testing.Extensions.TrxReport/InternalAPI/InternalAPI.Unshipped.txt
index 3b91624791..457e1df4d2 100644
--- a/src/Platform/Microsoft.Testing.Extensions.TrxReport/InternalAPI/InternalAPI.Unshipped.txt
+++ b/src/Platform/Microsoft.Testing.Extensions.TrxReport/InternalAPI/InternalAPI.Unshipped.txt
@@ -1,4 +1,8 @@
#nullable enable
+const Microsoft.Testing.Extensions.TrxReport.Abstractions.TrxReportEngine.TrxArtifactKind = "microsoft.testing.trx" -> string!
+const Microsoft.Testing.Platform.IPC.FileArtifactMessageFieldsId.Kind = 7 -> ushort
+static Microsoft.Testing.Extensions.TrxReport.Abstractions.TrxReportEngine.Merge(System.Collections.Generic.IReadOnlyList! inputReports, System.Guid runId, string! runName) -> System.Xml.Linq.XDocument!
+static Microsoft.Testing.Extensions.TrxReport.Abstractions.TrxReportEngine.MergeToFileAsync(System.Collections.Generic.IReadOnlyList! inputPaths, string! outputPath, System.Guid runId, string! runName, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task!
const Microsoft.Testing.Platform.Helpers.EnvironmentVariableConstants.TESTINGPLATFORM_PIPE_DIRECTORY = "TESTINGPLATFORM_PIPE_DIRECTORY" -> string!
Microsoft.Testing.Platform.Helpers.DisposeHelper
static Microsoft.Testing.Platform.Helpers.DisposeHelper.DisposeAsync(object? obj) -> System.Threading.Tasks.Task!
@@ -15,3 +19,6 @@ static Microsoft.Testing.Platform.IPC.Serializers.BaseSerializer.WriteExecutionS
static Microsoft.Testing.Platform.IPC.Serializers.BaseSerializer.WriteListPayload(System.IO.Stream! stream, ushort fieldId, T[]? list, System.Action! writeItem) -> void
const Microsoft.Testing.Platform.IPC.FailedTestResultMessageFieldsId.Expected = 10 -> ushort
const Microsoft.Testing.Platform.IPC.FailedTestResultMessageFieldsId.Actual = 11 -> ushort
+Microsoft.Testing.Extensions.MergeOutputFileHelper
+static Microsoft.Testing.Extensions.MergeOutputFileHelper.EnsureOutputDoesNotAliasInput(System.Collections.Generic.IReadOnlyList! inputPaths, string! outputPath) -> void
+static Microsoft.Testing.Extensions.MergeOutputFileHelper.WriteViaTemporarySiblingAsync(string! outputPath, System.Func! writeToTempAsync) -> System.Threading.Tasks.Task!
diff --git a/src/Platform/Microsoft.Testing.Extensions.TrxReport/Microsoft.Testing.Extensions.TrxReport.csproj b/src/Platform/Microsoft.Testing.Extensions.TrxReport/Microsoft.Testing.Extensions.TrxReport.csproj
index a17ef773ea..f62c76c9c0 100644
--- a/src/Platform/Microsoft.Testing.Extensions.TrxReport/Microsoft.Testing.Extensions.TrxReport.csproj
+++ b/src/Platform/Microsoft.Testing.Extensions.TrxReport/Microsoft.Testing.Extensions.TrxReport.csproj
@@ -34,6 +34,7 @@
+
diff --git a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxDataConsumer.cs b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxDataConsumer.cs
index 20afa70819..95807e83df 100644
--- a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxDataConsumer.cs
+++ b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxDataConsumer.cs
@@ -251,7 +251,7 @@ await _outputDisplay.DisplayAsync(
// If we are running with out-of-process mode, we communicate via pipe to the TestHostController and send the ReportFileNameRequest.
if (!TrxModeHelpers.ShouldUseOutOfProcessTrxGeneration(_commandLineOptionsService))
{
- await _messageBus.PublishAsync(this, new SessionFileArtifact(testSessionContext.SessionUid, new FileInfo(reportFileName), ExtensionResources.TrxReportArtifactDisplayName, ExtensionResources.TrxReportArtifactDescription)).ConfigureAwait(false);
+ await _messageBus.PublishAsync(this, new SessionFileArtifact(testSessionContext.SessionUid, new FileInfo(reportFileName), ExtensionResources.TrxReportArtifactDisplayName, ExtensionResources.TrxReportArtifactDescription, TrxReportEngine.TrxArtifactKind)).ConfigureAwait(false);
}
else
{
diff --git a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxProcessLifetimeHandler.cs b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxProcessLifetimeHandler.cs
index 3458995d97..bb34aaab29 100644
--- a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxProcessLifetimeHandler.cs
+++ b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxProcessLifetimeHandler.cs
@@ -224,7 +224,8 @@ await _messageBus.PublishAsync(
new FileArtifact(
new FileInfo(fileName),
ExtensionResources.TrxReportArtifactDisplayName,
- ExtensionResources.TrxReportArtifactDescription)).ConfigureAwait(false);
+ ExtensionResources.TrxReportArtifactDescription,
+ TrxReportEngine.TrxArtifactKind)).ConfigureAwait(false);
TryDeleteStreamingSidecar();
return;
@@ -260,7 +261,7 @@ await _messageBus.PublishAsync(
await trxReportGeneratorEngine.AddArtifactsAsync(trxFile, artifacts).ConfigureAwait(false);
}
- await _messageBus.PublishAsync(this, new FileArtifact(trxFile, ExtensionResources.TrxReportArtifactDisplayName, ExtensionResources.TrxReportArtifactDescription)).ConfigureAwait(false);
+ await _messageBus.PublishAsync(this, new FileArtifact(trxFile, ExtensionResources.TrxReportArtifactDisplayName, ExtensionResources.TrxReportArtifactDescription, TrxReportEngine.TrxArtifactKind)).ConfigureAwait(false);
// Best-effort orphan cleanup. On the happy path the test host normally deletes its own
// sidecar in TrxReportGenerator.GenerateReportAndCleanupAsync, but if its CompleteAsync timed
diff --git a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs
new file mode 100644
index 0000000000..8197428ebf
--- /dev/null
+++ b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs
@@ -0,0 +1,1125 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Microsoft.Testing.Platform;
+
+namespace Microsoft.Testing.Extensions.TrxReport.Abstractions;
+
+internal sealed partial class TrxReportEngine
+{
+ ///
+ /// Merges several already-produced TRX reports into a single TRX document.
+ ///
+ ///
+ /// This is a pure, invocation-agnostic XML-level merge: it does no I/O and reads no clock, so a
+ /// user-facing merge tool and an SDK-orchestrated post-processor can share it and, given the same
+ /// / and inputs, produce equivalent output. The
+ /// emitted TestSettings id is derived deterministically from , so
+ /// identical inputs reproduce byte-for-byte identical XML (RFC 018 idempotency; the orchestrator may
+ /// retry).
+ ///
+ /// Merge rules:
+ ///
+ /// - Results and TestEntries are unioned as-is; TestDefinitions are deduplicated by id (ids are derived deterministically from each test's UID and the schema forbids duplicates).
+ /// - TestLists are deduplicated by id (the well-known lists are shared across files).
+ /// - Counters attributes are summed; Times use the earliest creation/queuing/start and latest finish derived from the inputs (attributes no input supplies are omitted).
+ /// - RunInfos (crash/exit diagnostics) and CollectorDataEntries (attachment references) are carried across from every input's ResultSummary.
+ /// - The result summary outcome is Failed if any input failed, otherwise Completed.
+ /// - Attachment hrefs inside CollectorDataEntries are carried as-is; because they are relative to each input's deployment root, the physical attachment files are only relocated to the merged deployment root by (which has the source paths). Callers of the in-memory that need resolvable attachments should relocate them separately.
+ ///
+ ///
+ ///
+ internal static XDocument Merge(IReadOnlyList inputReports, Guid runId, string runName)
+ {
+ if (runName is null)
+ {
+ throw new ArgumentNullException(nameof(runName));
+ }
+
+ if (inputReports is null)
+ {
+ throw new ArgumentNullException(nameof(inputReports));
+ }
+
+ if (inputReports.Count == 0)
+ {
+ throw new ArgumentException("At least one TRX report is required to merge.", nameof(inputReports));
+ }
+
+ var mergedResults = new XElement(NamespaceUri + "Results");
+ var mergedTestDefinitions = new XElement(NamespaceUri + "TestDefinitions");
+ var mergedTestEntries = new XElement(NamespaceUri + "TestEntries");
+ var mergedTestLists = new XElement(NamespaceUri + "TestLists");
+ var seenTestListIds = new HashSet(StringComparer.OrdinalIgnoreCase);
+
+ // TestDefinition ids are derived deterministically from each test's UID (assembly file name plus
+ // test identity), so the same test discovered in more than one input yields the same id — but a
+ // multi-TFM merge can produce definitions that share an id yet differ (e.g. different storage /
+ // codeBase). We keep identical definitions deduplicated (the TRX schema forbids duplicate
+ // ), but remap a materially-different same-id definition to a fresh deterministic
+ // id and rewrite that input's testId references, so module-specific definitions are not lost.
+ var definitionsById = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ // Run-level diagnostics (), collector attachments () and run-level
+ // result files (, produced by VSTest) live under ; carry them across so
+ // merged reports don't silently lose crash/exit messages or attachment references.
+ var mergedRunInfos = new XElement(NamespaceUri + "RunInfos");
+ var mergedCollectorDataEntries = new XElement(NamespaceUri + "CollectorDataEntries");
+ var mergedResultFiles = new XElement(NamespaceUri + "ResultFiles");
+
+ // VSTest records run-level skipped/informational messages under /