Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
e1a52d8
Add artifact merge engines and Kind metadata flow (RFC 018 phase 1)
Evangelink Jul 9, 2026
03364a8
Merge remote-tracking branch 'origin/main' into dev/amauryleve/miniat…
Evangelink Jul 13, 2026
3d61cd4
Address review: TRX merge fidelity + out-of-process Kind
Evangelink Jul 13, 2026
3c482cd
Merge remote-tracking branch 'origin/main' into dev/amauryleve/miniat…
Evangelink Jul 13, 2026
46448d7
Address review: harden TRX attachment relocation
Evangelink Jul 13, 2026
4ea04cc
Update .xlf files to be in sync with .resx
Evangelink Jul 14, 2026
a05a088
Merge remote-tracking branch 'origin/main' into dev/amauryleve/miniat…
Evangelink Jul 14, 2026
3a712c1
Address review: destination reparse hardening, Kind assertion, revert…
Evangelink Jul 14, 2026
a5dab5b
Address review: ancestor-symlink hardening + producer-to-wire Kind tests
Evangelink Jul 14, 2026
a468d2e
Address review: CTRF tool/env attribution + TRX cancellation & path-f…
Evangelink Jul 14, 2026
1c197f4
Address review: TRX nested-overlap relocation + CTRF full tool identity
Evangelink Jul 14, 2026
1400070
Merge branch 'main' into dev/amauryleve/miniature-broccoli
Evangelink Jul 14, 2026
0bf2a16
Merge branch 'main' into dev/amauryleve/miniature-broccoli
Copilot Jul 15, 2026
011ce7c
Merge branch 'main' into dev/amauryleve/miniature-broccoli
Evangelink Jul 15, 2026
579f8b2
Address review: CTRF test-level timing fallback + TRX nested staging …
Evangelink Jul 15, 2026
97dcbb0
Address review: never delete input trees during TRX attachment reloca…
Evangelink Jul 15, 2026
92aae3a
Fix IDE0008 CI break + address review: relocation confinement, stagin…
Evangelink Jul 15, 2026
109ab51
Make report merges deterministic and non-destructive (RFC 018 review)
Evangelink Jul 15, 2026
df5c7c6
Fix TRX per-test ResultFile relocation, naming, and IDE0008 (RFC 018 …
Evangelink Jul 15, 2026
c14a6f9
Fix IDE0007 in merge test (use var for apparent type)
Evangelink Jul 15, 2026
02c0bef
Harden report-merge filesystem handling (RFC 018 review)
Evangelink Jul 15, 2026
bac64fb
Copy only referenced attachments in TRX merge (RFC 018 review)
Evangelink Jul 15, 2026
2b7a860
Drop unrelocated references and detect symlinked-parent output aliase…
Evangelink Jul 15, 2026
31f43de
Fix merge semantics: outcome, definition dedup, environment, referenc…
Evangelink Jul 15, 2026
d582d47
Fix UriAttachment schema validity, dangling dest symlinks, netstandar…
Evangelink Jul 15, 2026
ecdc9f4
Harden path containment, hardlink copy, top-level bail, and env unani…
Evangelink Jul 15, 2026
e68ed42
Preserve rooted attachments (RFC 018), normalize separators, validate…
Evangelink Jul 15, 2026
6cdc1b1
Require CTRF reportFormat, carry run-level ResultFiles, preserve root…
Evangelink Jul 15, 2026
d1b8007
Merge run-level Output, reject empty input early, filesystem-aware al…
Evangelink Jul 16, 2026
f378b5d
Probe output-path case sensitivity at its own directory, not temp (re…
Evangelink Jul 16, 2026
f91fcb2
Extract shared merge file I/O + alias checks into MergeOutputFileHelp…
Evangelink Jul 16, 2026
bd927b5
Fix macOS symlink-alias canonicalization and address merge review com…
Evangelink Jul 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
Comment thread
Copilot marked this conversation as resolved.
Outdated
// 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;

/// <summary>
/// Merges several already-produced CTRF JSON reports into a single CTRF document.
/// </summary>
/// <remarks>
/// 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:
/// <list type="bullet">
/// <item><description><c>results.tests[]</c> arrays are concatenated as-is.</description></item>
/// <item><description><c>results.summary</c> counters are re-derived by counting the merged <c>tests[]</c> (so <c>summary.tests</c> always matches the array length); <c>start</c>/<c>stop</c> use the earliest/latest across inputs, <c>duration</c> is the resulting span.</description></item>
/// <item><description><c>reportFormat</c>, <c>specVersion</c>, <c>tool</c> and <c>environment</c> are taken from the first report; <c>reportId</c> is freshly generated.</description></item>
Comment thread
Evangelink marked this conversation as resolved.
Outdated
/// </list>
/// </remarks>
internal static class CtrfReportMerger
{
internal static string Merge(IReadOnlyList<string> 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();

long? earliestStart = null;
long? latestStop = null;

foreach (string reportJson in inputReports)
{
if (JsonNode.Parse(reportJson) is not JsonObject root)
{
continue;
}
Comment thread
Evangelink marked this conversation as resolved.

first ??= root;

JsonNode? results = root["results"];
if (results?["tests"] is JsonArray testArray)
{
foreach (JsonNode? test in testArray)
{
mergedTests.Add(test?.DeepClone());
}
}

JsonNode? summary = results?["summary"];
if (summary is not null)
{
if (TryReadLong(summary, "start", out long start) && (earliestStart is null || start < earliestStart))
Comment thread
Evangelink marked this conversation as resolved.
Outdated
{
earliestStart = start;
}

if (TryReadLong(summary, "stop", out long stop) && (latestStop is null || stop > latestStop))
{
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();
if (first["results"]?["tool"] is JsonNode tool)
{
resultsObject["tool"] = tool.DeepClone();
}
Comment thread
Evangelink marked this conversation as resolved.
Outdated

resultsObject["summary"] = summaryObject;

if (first["results"]?["environment"] is JsonNode environment)
{
resultsObject["environment"] = environment.DeepClone();
}
Comment thread
Evangelink marked this conversation as resolved.
Outdated

resultsObject["tests"] = mergedTests;

var merged = new JsonObject
{
["reportFormat"] = first["reportFormat"]?.DeepClone() ?? "CTRF",
["specVersion"] = first["specVersion"]?.DeepClone() ?? "0.0.0",
["reportId"] = Guid.NewGuid().ToString("D"),
Comment thread
Evangelink marked this conversation as resolved.
Outdated
["timestamp"] = DateTimeOffset.FromUnixTimeMilliseconds(stopMs).ToString("O", CultureInfo.InvariantCulture),
["results"] = resultsObject,
};

if (first["generatedBy"] is JsonNode generatedBy)
{
merged["generatedBy"] = generatedBy.DeepClone();
}
Comment thread
Evangelink marked this conversation as resolved.
Outdated

return merged.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
}

internal static async Task MergeToFileAsync(
IReadOnlyList<string> inputPaths,
string outputPath,
CancellationToken cancellationToken)
{
if (inputPaths is null)
{
throw new ArgumentNullException(nameof(inputPaths));
}

if (outputPath is null)
{
throw new ArgumentNullException(nameof(outputPath));
}

var reports = new List<string>(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);
}

#if NETCOREAPP
await File.WriteAllTextAsync(outputPath, merged, cancellationToken).ConfigureAwait(false);
#else
File.WriteAllText(outputPath, merged);
Comment thread
Evangelink marked this conversation as resolved.
Outdated
await Task.CompletedTask.ConfigureAwait(false);
#endif
}

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;
}
}
Original file line number Diff line number Diff line change
@@ -1,2 +1,6 @@
#nullable enable
virtual Microsoft.Testing.Extensions.ReportGeneratorBase<TGenerator, TCapturedTestResult>.ArtifactKind.get -> string?
Microsoft.Testing.Extensions.CtrfReport.CtrfReportMerger
static Microsoft.Testing.Extensions.CtrfReport.CtrfReportMerger.Merge(System.Collections.Generic.IReadOnlyList<string!>! inputReports) -> string!
static Microsoft.Testing.Extensions.CtrfReport.CtrfReportMerger.MergeToFileAsync(System.Collections.Generic.IReadOnlyList<string!>! inputPaths, string! outputPath, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task!
static Microsoft.Testing.Platform.Services.ArtifactNamingHelper.ResolveAndSanitize(string! template, string! processName, string! processId, System.DateTimeOffset timestamp, System.Func<string!, string!>! sanitizeLeafFileName) -> string!
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#nullable enable
const Microsoft.Testing.Platform.IPC.FileArtifactMessageFieldsId.Kind = 7 -> ushort
static Microsoft.Testing.Platform.Services.ArtifactNamingHelper.ResolveAndSanitize(string! template, string! processName, string! processId, System.DateTimeOffset timestamp, System.Func<string!, string!>! sanitizeLeafFileName) -> string!
static Microsoft.Testing.Platform.IPC.Serializers.BaseSerializer.ReadFields(System.IO.Stream! stream, System.Func<ushort, int, bool>! tryReadField) -> void
static Microsoft.Testing.Platform.IPC.Serializers.BaseSerializer.TryReadExecutionScopedField(System.IO.Stream! stream, ushort fieldId, int fieldSize, ref string? executionId, ref string? instanceId) -> bool
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
#nullable enable
virtual Microsoft.Testing.Extensions.ReportGeneratorBase<TGenerator, TCapturedTestResult>.ArtifactKind.get -> string?
static Microsoft.Testing.Platform.Services.ArtifactNamingHelper.ResolveAndSanitize(string! template, string! processName, string! processId, System.DateTimeOffset timestamp, System.Func<string!, string!>! sanitizeLeafFileName) -> string!
Original file line number Diff line number Diff line change
@@ -1,2 +1,6 @@
#nullable enable
virtual Microsoft.Testing.Extensions.ReportGeneratorBase<TGenerator, TCapturedTestResult>.ArtifactKind.get -> string?
Microsoft.Testing.Extensions.JUnitReport.JUnitReportMerger
static Microsoft.Testing.Extensions.JUnitReport.JUnitReportMerger.Merge(System.Collections.Generic.IReadOnlyList<System.Xml.Linq.XDocument!>! inputReports, string! reportName) -> System.Xml.Linq.XDocument!
static Microsoft.Testing.Extensions.JUnitReport.JUnitReportMerger.MergeToFileAsync(System.Collections.Generic.IReadOnlyList<string!>! inputPaths, string! outputPath, string! reportName, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task!
static Microsoft.Testing.Platform.Services.ArtifactNamingHelper.ResolveAndSanitize(string! template, string! processName, string! processId, System.DateTimeOffset timestamp, System.Func<string!, string!>! sanitizeLeafFileName) -> string!
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading