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 / (see + // TrxReportEngine.Metadata.cs). Collect each input's Output element so those messages are merged + // rather than silently dropped. + var resultSummaryOutputs = new List(); + + // Preserve the order in which counter attributes are first seen so the merged + // element keeps the well-known TRX attribute ordering. + var counterAttributeOrder = new List(); + var counterSums = new Dictionary(StringComparer.Ordinal); + + bool anyFailure = false; + DateTimeOffset? earliestCreation = null; + DateTimeOffset? earliestQueuing = null; + DateTimeOffset? earliestStart = null; + DateTimeOffset? latestFinish = null; + int inputIndex = 0; + + foreach (XDocument report in inputReports) + { + XElement? testRun = report.Root; + if (testRun is null) + { + inputIndex++; + continue; + } + + // Merge TestDefinitions first to build this input's id remap, then clone Results/TestEntries + // with that remap applied so their testId references resolve to the right definition. + Dictionary idRemap = MergeTestDefinitions(FindChild(testRun, "TestDefinitions"), mergedTestDefinitions, definitionsById, inputIndex); + CloneWithRemappedTestIds(FindChild(testRun, "Results"), mergedResults, idRemap); + CloneWithRemappedTestIds(FindChild(testRun, "TestEntries"), mergedTestEntries, idRemap); + + XElement? testLists = FindChild(testRun, "TestLists"); + if (testLists is not null) + { + foreach (XElement testList in testLists.Elements()) + { + string? id = testList.Attribute("id")?.Value; + if (id is null || seenTestListIds.Add(id)) + { + mergedTestLists.Add(new XElement(testList)); + } + } + } + + XElement? resultSummary = FindChild(testRun, "ResultSummary"); + if (resultSummary is not null) + { + // A successful run's summary outcome is "Completed" (or "Passed"); anything else + // (Failed, Error, Aborted, Timeout, Inconclusive, …) is an unsuccessful run and must not + // be flattened to "Completed" in the merged report. + string? outcome = resultSummary.Attribute("outcome")?.Value; + if (!RoslynString.IsNullOrEmpty(outcome) + && !string.Equals(outcome, "Completed", StringComparison.OrdinalIgnoreCase) + && !string.Equals(outcome, "Passed", StringComparison.OrdinalIgnoreCase)) + { + anyFailure = true; + } + + AccumulateCounters(FindChild(resultSummary, "Counters"), counterAttributeOrder, counterSums); + CloneChildrenInto(FindChild(resultSummary, "RunInfos"), mergedRunInfos); + CloneChildrenInto(FindChild(resultSummary, "CollectorDataEntries"), mergedCollectorDataEntries); + CloneChildrenInto(FindChild(resultSummary, "ResultFiles"), mergedResultFiles); + if (FindChild(resultSummary, "Output") is { } output) + { + resultSummaryOutputs.Add(output); + } + } + + XElement? times = FindChild(testRun, "Times"); + if (times is not null) + { + // Each Times attribute is tracked independently from its own inputs rather than fabricated + // from the start: creation and queuing predate execution and cannot be derived from start, + // so any that no input supplies is omitted from the merged report instead of invented. + if (TryParseDateTimeOffset(times.Attribute("creation")?.Value, out DateTimeOffset creation) + && (earliestCreation is null || creation < earliestCreation)) + { + earliestCreation = creation; + } + + if (TryParseDateTimeOffset(times.Attribute("queuing")?.Value, out DateTimeOffset queuing) + && (earliestQueuing is null || queuing < earliestQueuing)) + { + earliestQueuing = queuing; + } + + if (TryParseDateTimeOffset(times.Attribute("start")?.Value, out DateTimeOffset start) + && (earliestStart is null || start < earliestStart)) + { + earliestStart = start; + } + + if (TryParseDateTimeOffset(times.Attribute("finish")?.Value, out DateTimeOffset finish) + && (latestFinish is null || finish > latestFinish)) + { + latestFinish = finish; + } + } + + inputIndex++; + } + + if (counterSums.TryGetValue("failed", out long failedCount) && failedCount > 0) + { + anyFailure = true; + } + + if (counterSums.TryGetValue("error", out long errorCount) && errorCount > 0) + { + anyFailure = true; + } + + if (counterSums.TryGetValue("timeout", out long timeoutCount) && timeoutCount > 0) + { + anyFailure = true; + } + + if (counterSums.TryGetValue("aborted", out long abortedCount) && abortedCount > 0) + { + anyFailure = true; + } + + var mergedTestRun = new XElement( + NamespaceUri + "TestRun", + new XAttribute("id", runId), + new XAttribute("name", runName)); + + mergedTestRun.Add(BuildTimes(earliestCreation, earliestQueuing, earliestStart, latestFinish)); + mergedTestRun.Add(BuildTestSettings(runId, runName)); + mergedTestRun.Add(mergedResults); + mergedTestRun.Add(mergedTestDefinitions); + mergedTestRun.Add(mergedTestEntries); + mergedTestRun.Add(mergedTestLists); + mergedTestRun.Add(BuildResultSummary(anyFailure ? "Failed" : "Completed", counterAttributeOrder, counterSums, MergeResultSummaryOutputs(resultSummaryOutputs), mergedRunInfos, mergedCollectorDataEntries, mergedResultFiles)); + + return new XDocument(new XDeclaration("1.0", "UTF-8", null), mergedTestRun); + } + + /// + /// Loads the given TRX files, merges them (see ) and writes the result to . + /// + internal static async Task MergeToFileAsync( + IReadOnlyList inputPaths, + string outputPath, + Guid runId, + string runName, + 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 output-directory creation and attachment relocation would already have touched the disk. + if (inputPaths.Count == 0) + { + throw new ArgumentException("At least one TRX report is required to merge.", nameof(inputPaths)); + } + + // RFC 018 treats per-module inputs as read-only and requires them to remain on disk. The merged + // TRX is written with File.Create (truncating); 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(); + reports.Add(XDocument.Load(inputPath)); + } + + string outputDirectory = Path.GetDirectoryName(outputPath) is { Length: > 0 } dir + ? dir + : Directory.GetCurrentDirectory(); + Directory.CreateDirectory(outputDirectory); + + // Attachment hrefs inside CollectorDataEntries / ResultFiles are relative to each input's + // deployment root (they look like "/" and physically live under + // "//In/..."). Relocate those trees into the merged report's + // deployment root — isolated per input so identical file names from different modules do not + // collide — and rewrite the input's hrefs to match, before the merge clones them. + // The merged deployment root is made unique per run (see GetConfinedDeploymentRootLeaf) so this + // relocation always writes into a fresh tree and can never mutate the attachments referenced by a + // previously committed merged TRX at the same output path; a failed serialization therefore leaves + // that prior report and its files consistent, and only orphaned files under the new root remain. + // Best-effort and path-confined: failures never fail the merge. + RelocateAttachments(inputPaths, reports, outputDirectory, runId, runName, cancellationToken); + + XDocument merged = Merge(reports, runId, runName); + + // 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 XElement? FindChild(XElement parent, string localName) + => parent.Elements().FirstOrDefault(e => string.Equals(e.Name.LocalName, localName, StringComparison.Ordinal)); + + /// + /// Copies each input report's referenced attachment files into a per-input isolated folder + /// under the merged report's deployment root and rewrites that input's references to match, so + /// attachments carried into the merged TRX keep resolving without one module's files shadowing + /// another's. Only the files actually referenced by the report are copied (never a whole directory + /// tree), which keeps the operation non-destructive (inputs stay read-only, RFC 018), bounded (no + /// recursion or unbounded accumulation when the output is nested inside a source), and confined + /// (runName and each input's runDeploymentRoot are attacker-influenced). Any copy + /// failure is swallowed so it never fails the merge (never-fail-the-run invariant); a reference that + /// cannot be relocated is dropped rather than left dangling or pointing outside the deployment root. + /// + private static void RelocateAttachments(IReadOnlyList inputPaths, IReadOnlyList reports, string outputDirectory, Guid runId, string runName, CancellationToken cancellationToken) + { + string outputFull = Path.GetFullPath(outputDirectory); + string mergedDeploymentRoot = GetConfinedDeploymentRootLeaf(runId, runName); + string mergedRootFull = Path.GetFullPath(Path.Combine(outputFull, mergedDeploymentRoot)); + + // The confined leaf can no longer be '.' or '..', but keep the defensive lexical/reparse checks: + // reject if the deployment root escapes the output directory or any component below it is a + // reparse point (a symlink/junction there would resolve writes outside the confined root). We + // cannot set up a safe merged root, so drop every input's references before bailing — otherwise + // they would be emitted unchanged and resolve through the unsafe root. + if (!IsUnderDirectory(mergedRootFull, outputFull) || HasReparsePointComponent(mergedRootFull, outputFull)) + { + DropAllReferences(reports); + return; + } + + string mergedInRoot = Path.Combine(mergedRootFull, "In"); + + // On a reused output directory the merged 'In' root could pre-exist as a junction/symlink that + // would redirect every copy below it outside the confined merged root. Remove such a link so a + // fresh, real directory is created instead. + if (Directory.Exists(mergedInRoot) && IsReparsePoint(mergedInRoot)) + { + try + { + Directory.Delete(mergedInRoot); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // The unsafe link remains; drop every input's references so none resolve through it. + DropAllReferences(reports); + return; + } + } + + for (int i = 0; i < inputPaths.Count && i < reports.Count; i++) + { + // Cancellation must interrupt an otherwise long sequence of file copies; check before each + // input (and inside TryCopyReferencedFile) and let OperationCanceledException propagate rather + // than being swallowed as a best-effort failure below. + cancellationToken.ThrowIfCancellationRequested(); + + try + { + string? inputDirectory = Path.GetDirectoryName(Path.GetFullPath(inputPaths[i])); + string? inputDeploymentRoot = reports[i].Root is { } root + ? FindChild(root, "TestSettings")?.Elements().FirstOrDefault(e => string.Equals(e.Name.LocalName, "Deployment", StringComparison.Ordinal))?.Attribute("runDeploymentRoot")?.Value + : null; + + if (RoslynString.IsNullOrEmpty(inputDirectory) || RoslynString.IsNullOrEmpty(inputDeploymentRoot)) + { + // No resolvable source for this input's attachments. Its references are relative to the + // input's own deployment root, so carrying them unchanged into the merged report (which + // has a different deployment root) would dangle — drop them. + DropAllAttachmentReferences(reports[i]); + continue; + } + + // runDeploymentRoot comes straight from the input TRX; a rooted value or '..' segments + // could make the source tree escape the input report directory. Reject those, and also + // reject when any component from the input directory down to the source 'In' root is a + // reparse point (a symlink/junction there could redirect the read outside the input tree + // even though the lexical confinement check passes). + string sourceInRoot = Path.GetFullPath(Path.Combine(inputDirectory, inputDeploymentRoot, "In")); + if (!IsUnderDirectory(sourceInRoot, inputDirectory) + || !Directory.Exists(sourceInRoot) + || HasReparsePointComponent(sourceInRoot, inputDirectory)) + { + // The source cannot be safely relocated; drop this input's references so none dangle + // against the merged deployment root. + DropAllAttachmentReferences(reports[i]); + continue; + } + + // Equal roots: the source files already live at the merged deployment root under their + // original relative paths, so references are kept un-prefixed. Still validate them + // (dropping rooted/traversal references, and any pointing at a missing or symlinked file) + // so the path-confined contract holds even when nothing is copied. + if (string.Equals(sourceInRoot, mergedInRoot, PathComparison)) + { + RelocateReferencedAttachments(reports[i], prefix: string.Empty, sourceInRoot, mergedInRoot, relocate: false, cancellationToken); + continue; + } + + // Copy only the files the report references (never the whole tree), so an output nested + // inside a source neither recurses into its own destination nor accumulates across retries. + RelocateReferencedAttachments(reports[i], i.ToString(CultureInfo.InvariantCulture), sourceInRoot, mergedInRoot, relocate: true, cancellationToken); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) + { + // Best-effort: a failed attachment copy (including a malformed path from a hostile + // runDeploymentRoot, which Path.GetFullPath surfaces as ArgumentException/ + // NotSupportedException) must not fail the merge. Cancellation is not caught here. + // Relocation may have stopped part-way, leaving some references un-rewritten; drop all of + // this input's references so none dangle against the merged deployment root. + DropAllAttachmentReferences(reports[i]); + } + } + } + + /// + /// Removes every attachment reference from all reports. Used when the merged deployment root cannot be + /// set up safely and the whole relocation is abandoned, so no report keeps references that would + /// resolve through the unsafe root. + /// + private static void DropAllReferences(IReadOnlyList reports) + { + foreach (XDocument report in reports) + { + DropAllAttachmentReferences(report); + } + } + + /// + /// Removes the relative attachment references (<A> and <ResultFile>) from a + /// report. Used when an input's attachments cannot be relocated (missing/invalid source, or a failed + /// copy), so the merged report never carries a reference that would resolve against the merged + /// deployment root to a file that was never placed there. Rooted (absolute) references are preserved — + /// they resolve independently of the deployment root (RFC 018 keeps absolute paths resolvable). + /// + private static void DropAllAttachmentReferences(XDocument report) + { + if (report.Root is not { } root) + { + return; + } + + foreach (XElement element in root.Descendants().Where(e => e.Name.LocalName is "A" or "ResultFile").ToList()) + { + if (!IsRootedReference(element)) + { + RemoveReferenceElement(element); + } + } + } + + /// + /// Returns if an attachment reference resolves to an absolute path (and so is + /// preserved even when relocation is abandoned): a rooted href/path, or a + /// <ResultFile> whose owning UnitTestResult has a rooted + /// relativeResultsDirectory. + /// + private static bool IsRootedReference(XElement element) + { + if (element.Name.LocalName == "A") + { + return element.Attribute("href")?.Value is { } href && Path.IsPathRooted(href); + } + + // ResultFile: rooted if its own path is rooted, or its owning UnitTestResult's directory is rooted. + if (element.Attribute("path")?.Value is { } path && Path.IsPathRooted(path)) + { + return true; + } + + XElement? owningResult = element.Ancestors().FirstOrDefault(a => a.Name.LocalName == "UnitTestResult"); + return owningResult?.Attribute("relativeResultsDirectory")?.Value is { } directory && Path.IsPathRooted(directory); + } + + /// + /// Removes a dropped attachment reference. A collector <A> takes its owning + /// <UriAttachment> with it (the TRX schema requires exactly one <A> child, so + /// leaving an empty <UriAttachment> would be invalid); a <ResultFile> is + /// removed alone (an empty <ResultFiles> is schema-valid). + /// + private static void RemoveReferenceElement(XElement element) + { + if (element.Name.LocalName == "A" && element.Parent is { } parent && string.Equals(parent.Name.LocalName, "UriAttachment", StringComparison.Ordinal)) + { + parent.Remove(); + } + else + { + element.Remove(); + } + } + + private static void RelocateReferencedAttachments(XDocument report, string prefix, string sourceInRoot, string mergedInRoot, bool relocate, CancellationToken cancellationToken) + { + if (report.Root is not { } root) + { + return; + } + + // Attachment references come in two shapes with different resolution rules: + // * Collector attachments '' (and a standalone '' from a + // foreign producer) are relative to the deployment 'In' root, so the file is copied to + // In// and the value is prefixed. + // * Per-test '' under a UnitTestResult are resolved by consumers under + // In//, so the file is copied to + // In/// and the *directory* is prefixed once. + // Only referenced files are copied (never a whole tree), and a reference that is rooted, escapes + // the deployment root, or whose source file was not materialized is dropped. When + // is false (equal-roots case: the source already lives at the merged + // deployment root) references are validated and dropped the same way, but kept un-prefixed and + // nothing is copied. + // Materialize each pass first: a dropped reference is removed, and removing while enumerating the + // lazy Descendants() sequence would be unsafe. + foreach (XElement collectorAttachment in root.Descendants().Where(e => e.Name.LocalName == "A").ToList()) + { + RelocateReference(collectorAttachment, "href", relativeDirectory: null, prefix, sourceInRoot, mergedInRoot, relocate, cancellationToken); + } + + foreach (XElement unitTestResult in root.Descendants().Where(e => e.Name.LocalName == "UnitTestResult").ToList()) + { + RelocateResultFilesForUnitTestResult(unitTestResult, prefix, sourceInRoot, mergedInRoot, relocate, cancellationToken); + } + + foreach (XElement standaloneResultFile in root.Descendants() + .Where(e => e.Name.LocalName == "ResultFile" && e.Ancestors().All(a => a.Name.LocalName != "UnitTestResult")) + .ToList()) + { + RelocateReference(standaloneResultFile, "path", relativeDirectory: null, prefix, sourceInRoot, mergedInRoot, relocate, cancellationToken); + } + } + + /// + /// Relocates (or, when is false, validates in place) the per-test + /// ResultFile references of a single UnitTestResult. Consumers resolve them under + /// In/<relativeResultsDirectory>/<path>; when relocating, each referenced file is + /// copied to In/<prefix>/<relativeResultsDirectory>/<path> and the directory + /// is prefixed once. A reference that is rooted, escapes the root, or whose source file was not + /// materialized (missing or symlinked) is dropped in either mode. + /// + private static void RelocateResultFilesForUnitTestResult(XElement unitTestResult, string prefix, string sourceInRoot, string mergedInRoot, bool relocate, CancellationToken cancellationToken) + { + List resultFiles = [.. unitTestResult.Descendants().Where(e => e.Name.LocalName == "ResultFile")]; + if (resultFiles.Count == 0) + { + return; + } + + XAttribute? relativeDirectory = unitTestResult.Attribute("relativeResultsDirectory"); + string relativeDirectoryValue = relativeDirectory?.Value ?? string.Empty; + + if (relativeDirectory is null || RoslynString.IsNullOrEmpty(relativeDirectoryValue)) + { + // No owning directory: each path resolves at In/, like a collector href. + foreach (XElement resultFile in resultFiles) + { + RelocateReference(resultFile, "path", relativeDirectory: null, prefix, sourceInRoot, mergedInRoot, relocate, cancellationToken); + } + + return; + } + + // A rooted owning directory makes every ResultFile resolve to an absolute path; RFC 018 keeps + // absolute attachment paths resolvable, so preserve them unchanged (not relocated, not dropped). + if (Path.IsPathRooted(relativeDirectoryValue)) + { + return; + } + + // Copy (or validate) each referenced file. A rooted per-test path is absolute and preserved + // unchanged; a relative one that escapes the root or is not materialized is dropped; the rest are + // relocated. Prefix the directory once if any relative reference survived (only when relocating). + bool anyKept = false; + foreach (XElement resultFile in resultFiles) + { + XAttribute? pathAttribute = resultFile.Attribute("path"); + string path = pathAttribute?.Value ?? string.Empty; + if (Path.IsPathRooted(path)) + { + // Absolute path: preserved as-is, independent of the (relative) owning directory. + continue; + } + + string combined = relativeDirectoryValue + "/" + path; + if (EscapesRoot(combined) || !TryMaterializeOrValidateReference(sourceInRoot, mergedInRoot, prefix, combined, relocate, cancellationToken)) + { + resultFile.Remove(); + } + else + { + anyKept = true; + if (relocate && pathAttribute is not null) + { + // Normalize the kept path's separators to '/' so the reference resolves cross-platform, + // matching the separator-normalized copy destination. + pathAttribute.Value = path.Replace('\\', '/'); + } + } + } + + if (anyKept && relocate) + { + relativeDirectory.Value = prefix + "/" + relativeDirectoryValue.Replace('\\', '/'); + } + } + + /// + /// Relocates (or validates in place) the file referenced by . A rooted + /// (absolute) value is preserved unchanged — RFC 018 keeps absolute attachment paths resolvable — while + /// a value that escapes the deployment root, or a source file that was not materialized (missing, or a + /// skipped symlink), causes the reference to be dropped. When is true a + /// kept relative file is copied into the per-input folder and the reference is prefixed (separators + /// normalized to '/'); otherwise it is left unchanged. + /// + private static void RelocateReference(XElement element, string attributeName, string? relativeDirectory, string prefix, string sourceInRoot, string mergedInRoot, bool relocate, CancellationToken cancellationToken) + { + XAttribute? attribute = element.Attribute(attributeName); + if (attribute is null || RoslynString.IsNullOrEmpty(attribute.Value)) + { + return; + } + + // RFC 018 requires attachment URIs to survive merging and treats current absolute TRX paths as + // already resolvable, so a rooted value is preserved as-is (not relocated, not dropped). + if (Path.IsPathRooted(attribute.Value)) + { + return; + } + + string relative = relativeDirectory is null ? attribute.Value : relativeDirectory + "/" + attribute.Value; + if (EscapesRoot(relative) || !TryMaterializeOrValidateReference(sourceInRoot, mergedInRoot, prefix, relative, relocate, cancellationToken)) + { + RemoveReferenceElement(element); + return; + } + + if (relocate) + { + // Emit with forward slashes so a reference relocated from a Windows-produced TRX resolves on + // Unix (and vice versa), matching the separator-normalized copy destination. + attribute.Value = prefix + "/" + attribute.Value.Replace('\\', '/'); + } + } + + /// + /// Validates a single referenced attachment file under and, when + /// is true, copies it to the per-input folder under + /// . Returns (so the caller drops the reference) + /// when the source file is missing, is a symlink, sits behind a symlinked component, or when either + /// resolved path escapes its root. is already known to be non-rooted and + /// non-escaping. + /// + private static bool TryMaterializeOrValidateReference(string sourceInRoot, string mergedInRoot, string prefix, string relative, bool relocate, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + string normalized = relative.Replace('/', Path.DirectorySeparatorChar).Replace('\\', Path.DirectorySeparatorChar); + string sourceFile = Path.GetFullPath(Path.Combine(sourceInRoot, normalized)); + + // Refuse a source that escapes its root, is missing, is a symlink, or sits behind a symlinked + // component, so a link cannot redirect the read outside the confined tree. + if (!IsUnderDirectory(sourceFile, sourceInRoot) + || !File.Exists(sourceFile) + || IsReparsePoint(sourceFile) + || HasReparsePointComponent(Path.GetDirectoryName(sourceFile)!, sourceInRoot)) + { + return false; + } + + if (!relocate) + { + // Equal-roots: the file already lives at the merged deployment root; validated, nothing to copy. + return true; + } + + string destinationFile = Path.GetFullPath(Path.Combine(mergedInRoot, prefix, normalized)); + if (!IsUnderDirectory(destinationFile, mergedInRoot) + || HasReparsePointComponent(Path.GetDirectoryName(destinationFile)!, mergedInRoot)) + { + return false; + } + + Directory.CreateDirectory(Path.GetDirectoryName(destinationFile)!); + + // Remove ANY pre-existing destination entry — a regular file, a symlink (including a DANGLING one, + // which File.Exists reports as missing because it follows the link), or a hardlink — then copy + // WITHOUT overwrite. This guarantees a fresh file is written rather than a link being followed, or a + // hardlink's shared target (which lives outside the merged root) being overwritten in place. + DeleteDestinationEntry(destinationFile); + + File.Copy(sourceFile, destinationFile, overwrite: false); + return true; + } + + /// + /// Deletes any existing directory entry at — a regular file, a symlink/junction + /// (including a dangling one, which reports as missing because it + /// follows the link), or a hardlink — so a subsequent copy writes a fresh file instead of following a + /// link out of, or overwriting a hardlink's shared target outside, the confined root. A missing entry + /// is left untouched. + /// + private static void DeleteDestinationEntry(string path) + { +#if NETCOREAPP + // FileInfo.Exists is target-based (a dangling link reads as not-existing) while LinkTarget is + // non-null for any link entry, so together they cover regular files, hardlinks, and (dangling) links. + var info = new FileInfo(path); + if (info.Exists || info.LinkTarget is not null) + { + info.Delete(); + } +#else + if (File.Exists(path)) + { + File.Delete(path); + } +#endif + } + + /// + /// Returns if the relative resolves above its + /// own root (i.e. a .. segment pops past the start), meaning it would escape the confined + /// deployment directory once resolved. + /// + private static bool EscapesRoot(string relativePath) + { + int depth = 0; + foreach (string segment in relativePath.Split('/', '\\')) + { + if (segment is "" or ".") + { + continue; + } + + if (segment == "..") + { + depth--; + if (depth < 0) + { + return true; + } + } + else + { + depth++; + } + } + + return false; + } + + private static bool IsUnderDirectory(string candidateFullPath, string rootDirectory) + { + string rootFull = Path.GetFullPath(rootDirectory); + string rootWithSeparator = rootFull.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal) + ? rootFull + : rootFull + Path.DirectorySeparatorChar; + + StringComparison comparison = PathComparison; + return string.Equals(candidateFullPath, rootFull, comparison) + || candidateFullPath.StartsWith(rootWithSeparator, comparison); + } + + private static bool IsReparsePoint(string path) + => (File.GetAttributes(path) & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint; + + /// + /// Returns if any existing directory component strictly below + /// up to and including is a + /// reparse point (symlink/junction). Both paths are expected to be normalized full paths with + /// under . A symlinked ancestor + /// can redirect reads/writes outside a lexically-confined path, so callers reject such trees. + /// + private static bool HasReparsePointComponent(string candidateFullPath, string baseDirectory) + { + string baseFull = Path.GetFullPath(baseDirectory); + string current = candidateFullPath; + + while (!string.Equals(current, baseFull, PathComparison) && IsUnderDirectory(current, baseFull)) + { + if (Directory.Exists(current) && IsReparsePoint(current)) + { + return true; + } + + string? parent = Path.GetDirectoryName(current); + if (RoslynString.IsNullOrEmpty(parent) || string.Equals(parent, current, PathComparison)) + { + break; + } + + current = parent; + } + + return false; + } + + // PathComparison gates *containment* decisions (IsUnderDirectory / HasReparsePointComponent confining + // a source or destination under a root, and the equal-roots skip). Containment MUST be ordinal + // (case-sensitive): on a case-sensitive filesystem a hostile deployment root such as '../foo' resolves + // to the case-distinct sibling '/tmp/foo' of an input under '/tmp/Foo', and a case-insensitive check + // would wrongly accept it as confined and read the sibling's attachments. Treating case-distinct paths + // as different only makes containment MORE restrictive (skip/drop) — never an escape. + // + // The output-alias EQUALITY check is a different concern (two names for the SAME file) and is compared + // case-insensitively on canonicalized paths in EnsureOutputDoesNotAliasInput, independently of this. + private static StringComparison PathComparison => StringComparison.Ordinal; + + private static void CloneChildrenInto(XElement? source, XElement destination) + { + if (source is null) + { + return; + } + + foreach (XElement child in source.Elements()) + { + destination.Add(new XElement(child)); + } + } + + /// + /// Merges one input's TestDefinitions into the accumulator and returns the id remap to apply to + /// that input's testId references. An id not seen before is kept; an id whose definition is + /// identical to the one already kept is deduplicated (the schema forbids duplicate ids); an id whose + /// definition differs (e.g. the same test from another TFM with different storage) is remapped to a + /// fresh deterministic id and kept, so module-specific definitions are preserved. + /// + private static Dictionary MergeTestDefinitions(XElement? testDefinitions, XElement mergedTestDefinitions, Dictionary definitionsById, int inputIndex) + { + var remap = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (testDefinitions is null) + { + return remap; + } + + foreach (XElement definition in testDefinitions.Elements()) + { + string? id = definition.Attribute("id")?.Value; + if (id is null) + { + mergedTestDefinitions.Add(new XElement(definition)); + continue; + } + + if (!definitionsById.TryGetValue(id, out XElement? existing)) + { + definitionsById[id] = definition; + mergedTestDefinitions.Add(new XElement(definition)); + } + else if (XNode.DeepEquals(existing, definition)) + { + // Identical definition already kept — deduplicate. + } + else + { + string newId = RemapDefinitionId(id, inputIndex); + while (definitionsById.ContainsKey(newId)) + { + newId = RemapDefinitionId(newId, inputIndex); + } + + var clone = new XElement(definition); + clone.SetAttributeValue("id", newId); + definitionsById[newId] = clone; + mergedTestDefinitions.Add(clone); + remap[id] = newId; + } + } + + return remap; + } + + /// + /// Clones the children of into , rewriting any + /// testId attribute (on the child or its descendants) through so a + /// remapped TestDefinition's results/entries reference the right definition. + /// + private static void CloneWithRemappedTestIds(XElement? source, XElement destination, Dictionary remap) + { + if (source is null) + { + return; + } + + foreach (XElement child in source.Elements()) + { + var clone = new XElement(child); + if (remap.Count > 0) + { + foreach (XElement element in clone.DescendantsAndSelf()) + { + if (element.Attribute("testId") is { } testId && remap.TryGetValue(testId.Value, out string? newId)) + { + testId.Value = newId; + } + } + } + + destination.Add(clone); + } + } + + /// + /// Derives a stable, distinct id for a TestDefinition that collides with a materially-different one, + /// from the original id and the input index, so the remap is deterministic (RFC 018 idempotency). + /// + private static string RemapDefinitionId(string originalId, int inputIndex) + { + const ulong fnvPrime = 1099511628211UL; + ulong low = 14695981039346656037UL; + ulong high = 0x9E3779B97F4A7C15UL; + foreach (char c in originalId + "|" + inputIndex.ToString(CultureInfo.InvariantCulture)) + { + low = (low ^ c) * fnvPrime; + high = (high ^ c) * fnvPrime; + } + + byte[] bytes = new byte[16]; + BitConverter.GetBytes(low).CopyTo(bytes, 0); + BitConverter.GetBytes(high).CopyTo(bytes, 8); + return new Guid(bytes).ToString(); + } + + private static void AccumulateCounters(XElement? counters, List attributeOrder, Dictionary sums) + { + if (counters is null) + { + return; + } + + foreach (XAttribute attribute in counters.Attributes()) + { + string name = attribute.Name.LocalName; + if (!sums.ContainsKey(name)) + { + attributeOrder.Add(name); + sums[name] = 0; + } + + if (long.TryParse(attribute.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out long value)) + { + sums[name] += value; + } + } + } + + private static XElement BuildTimes(DateTimeOffset? earliestCreation, DateTimeOffset? earliestQueuing, DateTimeOffset? earliestStart, DateTimeOffset? latestFinish) + { + var times = new XElement(NamespaceUri + "Times"); + if (earliestCreation is { } creation) + { + times.SetAttributeValue("creation", creation); + } + + if (earliestQueuing is { } queuing) + { + times.SetAttributeValue("queuing", queuing); + } + + if (earliestStart is { } start) + { + times.SetAttributeValue("start", start); + } + + if (latestFinish is { } finish) + { + times.SetAttributeValue("finish", finish); + } + + return times; + } + + private static XElement BuildTestSettings(Guid runId, string runName) + { + var testSettings = new XElement( + NamespaceUri + "TestSettings", + new XAttribute("name", "default"), + new XAttribute("id", CreateDeterministicSettingsId(runId))); + testSettings.Add(new XElement(NamespaceUri + "Deployment", new XAttribute("runDeploymentRoot", GetConfinedDeploymentRootLeaf(runId, runName)))); + return testSettings; + } + + // Fixed namespace used to derive the (deterministic) TestSettings id from the run id, so the settings + // id is stable across retries yet distinct from the run id itself. + private static readonly Guid TestSettingsIdNamespace = new("b3f8f9d1-2e4a-4c6b-9f0d-7a1c2e5b8d40"); + + /// + /// Derives a stable TestSettings id from by XoR-ing it with a fixed + /// namespace, so identical inputs produce identical merged XML (RFC 018 idempotency) without emitting + /// the run id verbatim as the settings id. + /// + private static Guid CreateDeterministicSettingsId(Guid runId) + { + byte[] runBytes = runId.ToByteArray(); + byte[] namespaceBytes = TestSettingsIdNamespace.ToByteArray(); + byte[] result = new byte[16]; + for (int i = 0; i < result.Length; i++) + { + result[i] = (byte)(runBytes[i] ^ namespaceBytes[i]); + } + + return new Guid(result); + } + + /// + /// Produces a single, confined, per-run-unique deployment-root leaf from and + /// for use both in the emitted TestSettings/Deployment/@runDeploymentRoot + /// and in attachment relocation, so the two always agree and neither can escape the output directory. The + /// file-name sanitizer already replaces path separators and reserved names, so the only residual escape + /// values are . and .., which are prefixed to keep the leaf confined. The run id (which is + /// deterministic for a given logical run, preserving RFC 018 idempotency) is appended so a merge never + /// reuses the deployment tree of a previously committed report at the same output path — relocation then + /// cannot mutate that prior report's referenced files, and a failed merge leaves it consistent. + /// + private static string GetConfinedDeploymentRootLeaf(Guid runId, string runName) + { + string leaf = ReportFileNameSanitizer.ReplaceInvalidFileNameChars(runName); + leaf = leaf is "." or ".." ? "_" + leaf : leaf; + return leaf + "." + runId.ToString("N", CultureInfo.InvariantCulture); + } + + private static XElement BuildResultSummary(string outcome, List counterAttributeOrder, Dictionary counterSums, XElement? output, XElement runInfos, XElement collectorDataEntries, XElement resultFiles) + { + var counters = new XElement(NamespaceUri + "Counters"); + foreach (string name in counterAttributeOrder) + { + counters.SetAttributeValue(name, counterSums[name].ToString(CultureInfo.InvariantCulture)); + } + + var resultSummary = new XElement( + NamespaceUri + "ResultSummary", + new XAttribute("outcome", outcome), + counters); + + // Emit the optional children in TRX schema order (Counters, Output, RunInfos, CollectorDataEntries, + // ResultFiles), and only when they carry content, matching the single-run producer's shape. + if (output is not null) + { + resultSummary.Add(output); + } + + if (runInfos.HasElements) + { + resultSummary.Add(runInfos); + } + + if (collectorDataEntries.HasElements) + { + resultSummary.Add(collectorDataEntries); + } + + if (resultFiles.HasElements) + { + resultSummary.Add(resultFiles); + } + + return resultSummary; + } + + /// + /// Merges the run-level <Output> elements of every input ResultSummary into a single + /// one so run-level std streams and informational/skipped TextMessages survive the merge. The + /// scalar text children (StdOut/StdErr/DebugTrace) are concatenated and the + /// Message entries under TextMessages are unioned. Returns when + /// nothing is present. + /// + private static XElement? MergeResultSummaryOutputs(List outputs) + { + if (outputs.Count == 0) + { + return null; + } + + var stdOut = new StringBuilder(); + var stdErr = new StringBuilder(); + var debugTrace = new StringBuilder(); + var messages = new List(); + + foreach (XElement output in outputs) + { + AppendOutputText(stdOut, FindChild(output, "StdOut")); + AppendOutputText(stdErr, FindChild(output, "StdErr")); + AppendOutputText(debugTrace, FindChild(output, "DebugTrace")); + + if (FindChild(output, "TextMessages") is { } textMessages) + { + foreach (XElement message in textMessages.Elements().Where(e => string.Equals(e.Name.LocalName, "Message", StringComparison.Ordinal))) + { + messages.Add(new XElement(message)); + } + } + } + + var merged = new XElement(NamespaceUri + "Output"); + if (stdOut.Length > 0) + { + merged.Add(new XElement(NamespaceUri + "StdOut", stdOut.ToString())); + } + + if (stdErr.Length > 0) + { + merged.Add(new XElement(NamespaceUri + "StdErr", stdErr.ToString())); + } + + if (debugTrace.Length > 0) + { + merged.Add(new XElement(NamespaceUri + "DebugTrace", debugTrace.ToString())); + } + + if (messages.Count > 0) + { + var textMessages = new XElement(NamespaceUri + "TextMessages"); + textMessages.Add(messages); + merged.Add(textMessages); + } + + return merged.HasElements ? merged : null; + } + + private static void AppendOutputText(StringBuilder builder, XElement? element) + { + if (element is null || RoslynString.IsNullOrEmpty(element.Value)) + { + return; + } + + if (builder.Length > 0) + { + builder.Append('\n'); + } + + builder.Append(element.Value); + } + + private static bool TryParseDateTimeOffset(string? value, out DateTimeOffset result) + { + if (RoslynString.IsNullOrEmpty(value)) + { + result = default; + return false; + } + + return DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out result); + } +} diff --git a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.cs b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.cs index d0d7aecd35..4d29ccdb09 100644 --- a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.cs +++ b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.cs @@ -16,6 +16,12 @@ namespace Microsoft.Testing.Extensions.TrxReport.Abstractions; internal sealed partial class TrxReportEngine { + /// + /// The producer-asserted, reverse-DNS identifier of the TRX artifact format, used by + /// post-processing to group TRX reports for consolidation. + /// + internal const string TrxArtifactKind = "microsoft.testing.trx"; + private const string UnitTestTypeGuid = "13CDC9D9-DDB5-4fa4-A97D-D965CCFC6D4B"; private const string UncategorizedTestListId = "8C84FA94-04C1-424b-9868-57A2D4851A1D"; diff --git a/src/Platform/Microsoft.Testing.Platform/InternalAPI/InternalAPI.Unshipped.txt b/src/Platform/Microsoft.Testing.Platform/InternalAPI/InternalAPI.Unshipped.txt index 74486a2bdc..a358eb0be5 100644 --- a/src/Platform/Microsoft.Testing.Platform/InternalAPI/InternalAPI.Unshipped.txt +++ b/src/Platform/Microsoft.Testing.Platform/InternalAPI/InternalAPI.Unshipped.txt @@ -1,4 +1,13 @@ #nullable enable +const Microsoft.Testing.Platform.IPC.FileArtifactMessageFieldsId.Kind = 7 -> ushort +static Microsoft.Testing.Platform.IPC.DotnetTestDataConsumer.CreateFileArtifactMessages(string? executionId, Microsoft.Testing.Platform.Extensions.Messages.FileArtifact! fileArtifact) -> Microsoft.Testing.Platform.IPC.Models.FileArtifactMessages! +static Microsoft.Testing.Platform.IPC.DotnetTestDataConsumer.CreateFileArtifactMessages(string? executionId, Microsoft.Testing.Platform.Extensions.Messages.SessionFileArtifact! sessionFileArtifact) -> Microsoft.Testing.Platform.IPC.Models.FileArtifactMessages! +Microsoft.Testing.Platform.IPC.Models.FileArtifactMessage.Deconstruct(out string? FullPath, out string? DisplayName, out string? Description, out string? TestUid, out string? TestDisplayName, out string? SessionUid, out string? Kind) -> void +Microsoft.Testing.Platform.IPC.Models.FileArtifactMessage.FileArtifactMessage(string? FullPath, string? DisplayName, string? Description, string? TestUid, string? TestDisplayName, string? SessionUid, string? Kind) -> void +Microsoft.Testing.Platform.IPC.Models.FileArtifactMessage.Kind.get -> string? +Microsoft.Testing.Platform.IPC.Models.FileArtifactMessage.Kind.init -> void +*REMOVED*Microsoft.Testing.Platform.IPC.Models.FileArtifactMessage.Deconstruct(out string? FullPath, out string? DisplayName, out string? Description, out string? TestUid, out string? TestDisplayName, out string? SessionUid) -> void +*REMOVED*Microsoft.Testing.Platform.IPC.Models.FileArtifactMessage.FileArtifactMessage(string? FullPath, string? DisplayName, string? Description, string? TestUid, string? TestDisplayName, string? SessionUid) -> void 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.Services.ArtifactNamingHelper.ResolveAndSanitize(string! template, string! processName, string! processId, System.DateTimeOffset timestamp, System.Func! sanitizeLeafFileName) -> string! diff --git a/src/Platform/Microsoft.Testing.Platform/Messages/FileArtifacts.cs b/src/Platform/Microsoft.Testing.Platform/Messages/FileArtifacts.cs index 1a3e92bc11..c1659e6dd6 100644 --- a/src/Platform/Microsoft.Testing.Platform/Messages/FileArtifacts.cs +++ b/src/Platform/Microsoft.Testing.Platform/Messages/FileArtifacts.cs @@ -16,14 +16,48 @@ public class FileArtifact : PropertyBagData /// The file information. /// The display name. /// The description. + // This shipped constructor intentionally keeps its optional 'description' parameter for binary + // compatibility; the newer overload below adds 'kind'. Folding them into a single constructor + // would binary-break existing compiled callers of this 3-parameter overload, so RS0027 (which + // wants the optional-parameter overload to have the most parameters) is suppressed here. +#pragma warning disable RS0027 // API with optional parameter(s) should have the most parameters amongst its public overloads public FileArtifact(FileInfo fileInfo, string displayName, string? description = null) +#pragma warning restore RS0027 : base(displayName, description) => FileInfo = fileInfo; + /// + /// Initializes a new instance of the class with a producer-asserted artifact kind. + /// + /// The file information. + /// The display name. + /// The description. + /// + /// An optional producer-asserted, reverse-DNS identifier of the artifact format + /// (e.g. microsoft.testing.trx). Used by post-processing to group artifacts of + /// the same kind for consolidation. when the producer does not + /// declare a kind. + /// + [Experimental("TPEXP", UrlFormat = "https://aka.ms/testingplatform/diagnostics#{0}")] + public FileArtifact(FileInfo fileInfo, string displayName, string? description, string? kind) + : base(displayName, description) + { + FileInfo = fileInfo; + Kind = kind; + } + /// /// Gets the file information. /// public FileInfo FileInfo { get; } + /// + /// Gets the producer-asserted, reverse-DNS identifier of the artifact format + /// (e.g. microsoft.testing.trx), or when the producer + /// did not declare one. + /// + [Experimental("TPEXP", UrlFormat = "https://aka.ms/testingplatform/diagnostics#{0}")] + public string? Kind { get; } + /// public override string ToString() { @@ -73,14 +107,49 @@ public class SessionFileArtifact : DataWithSessionUid /// The file information. /// The display name. /// The description. + // This shipped constructor intentionally keeps its optional 'description' parameter for binary + // compatibility; the newer overload below adds 'kind'. Folding them into a single constructor + // would binary-break existing compiled callers of this 4-parameter overload, so RS0027 (which + // wants the optional-parameter overload to have the most parameters) is suppressed here. +#pragma warning disable RS0027 // API with optional parameter(s) should have the most parameters amongst its public overloads public SessionFileArtifact(SessionUid sessionUid, FileInfo fileInfo, string displayName, string? description = null) +#pragma warning restore RS0027 : base(displayName, description, sessionUid) => FileInfo = fileInfo; + /// + /// Initializes a new instance of the class with a producer-asserted artifact kind. + /// + /// The session UID. + /// The file information. + /// The display name. + /// The description. + /// + /// An optional producer-asserted, reverse-DNS identifier of the artifact format + /// (e.g. microsoft.testing.trx). Used by post-processing to group artifacts of + /// the same kind for consolidation. when the producer does not + /// declare a kind. + /// + [Experimental("TPEXP", UrlFormat = "https://aka.ms/testingplatform/diagnostics#{0}")] + public SessionFileArtifact(SessionUid sessionUid, FileInfo fileInfo, string displayName, string? description, string? kind) + : base(displayName, description, sessionUid) + { + FileInfo = fileInfo; + Kind = kind; + } + /// /// Gets the file information. /// public FileInfo FileInfo { get; } + /// + /// Gets the producer-asserted, reverse-DNS identifier of the artifact format + /// (e.g. microsoft.testing.trx), or when the producer + /// did not declare one. + /// + [Experimental("TPEXP", UrlFormat = "https://aka.ms/testingplatform/diagnostics#{0}")] + public string? Kind { get; } + /// public override string ToString() { diff --git a/src/Platform/Microsoft.Testing.Platform/PublicAPI/PublicAPI.Unshipped.txt b/src/Platform/Microsoft.Testing.Platform/PublicAPI/PublicAPI.Unshipped.txt index 1cb5ae5ad6..85f9e22fc0 100644 --- a/src/Platform/Microsoft.Testing.Platform/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/Platform/Microsoft.Testing.Platform/PublicAPI/PublicAPI.Unshipped.txt @@ -16,6 +16,10 @@ [TPINTERNAL]static Microsoft.Testing.Platform.Services.ServiceProviderExtensions.GetSystemClock(this System.IServiceProvider! serviceProvider) -> Microsoft.Testing.Platform.Helpers.IClock! [TPINTERNAL]virtual Microsoft.Testing.Platform.CommandLine.CommandLineOptionsProviderBase.ValidateCommandLineOptionsAsync(Microsoft.Testing.Platform.CommandLine.ICommandLineOptions! commandLineOptions) -> System.Threading.Tasks.Task! [TPINTERNAL]virtual Microsoft.Testing.Platform.CommandLine.CommandLineOptionsProviderBase.ValidateOptionArgumentsAsync(Microsoft.Testing.Platform.Extensions.CommandLine.CommandLineOption! commandOption, string![]! arguments) -> System.Threading.Tasks.Task! +[TPEXP]Microsoft.Testing.Platform.Extensions.Messages.FileArtifact.FileArtifact(System.IO.FileInfo! fileInfo, string! displayName, string? description, string? kind) -> void +[TPEXP]Microsoft.Testing.Platform.Extensions.Messages.FileArtifact.Kind.get -> string? +[TPEXP]Microsoft.Testing.Platform.Extensions.Messages.SessionFileArtifact.Kind.get -> string? +[TPEXP]Microsoft.Testing.Platform.Extensions.Messages.SessionFileArtifact.SessionFileArtifact(Microsoft.Testing.Platform.TestHost.SessionUid sessionUid, System.IO.FileInfo! fileInfo, string! displayName, string? description, string? kind) -> void [TPEXP]Microsoft.Testing.Platform.Services.IClientCapabilities [TPEXP]Microsoft.Testing.Platform.Services.IClientCapabilities.IsStateful.get -> bool [TPEXP]Microsoft.Testing.Platform.Services.IClientInfo.Capabilities.get -> Microsoft.Testing.Platform.Services.IClientCapabilities! diff --git a/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/DotnetTestDataConsumer.cs b/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/DotnetTestDataConsumer.cs index dc60d298ea..a362ed4eda 100644 --- a/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/DotnetTestDataConsumer.cs +++ b/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/DotnetTestDataConsumer.cs @@ -165,7 +165,8 @@ public async Task ConsumeAsync(IDataProducer dataProducer, IData value, Cancella artifact.Description ?? string.Empty, testNodeUpdateMessage.TestNode.Uid.Value, testNodeUpdateMessage.TestNode.DisplayName, - testNodeUpdateMessage.SessionUid.Value) + testNodeUpdateMessage.SessionUid.Value, + null) ]); await _dotnetTestConnection.SendMessageAsync(testFileArtifactMessages).ConfigureAwait(false); @@ -174,41 +175,52 @@ public async Task ConsumeAsync(IDataProducer dataProducer, IData value, Cancella break; case SessionFileArtifact sessionFileArtifact: - var fileArtifactMessages = new FileArtifactMessages( - _executionId, - DotnetTestConnection.InstanceId, - [ - new FileArtifactMessage( - sessionFileArtifact.FileInfo.FullName, - sessionFileArtifact.DisplayName, - sessionFileArtifact.Description ?? string.Empty, - string.Empty, - string.Empty, - sessionFileArtifact.SessionUid.Value) - ]); + FileArtifactMessages fileArtifactMessages = CreateFileArtifactMessages(_executionId, sessionFileArtifact); await _dotnetTestConnection.SendMessageAsync(fileArtifactMessages).ConfigureAwait(false); break; case FileArtifact fileArtifact: - fileArtifactMessages = new( - _executionId, - DotnetTestConnection.InstanceId, - [ - new FileArtifactMessage( - fileArtifact.FileInfo.FullName, - fileArtifact.DisplayName, - fileArtifact.Description ?? string.Empty, - string.Empty, - string.Empty, - string.Empty) - ]); + fileArtifactMessages = CreateFileArtifactMessages(_executionId, fileArtifact); await _dotnetTestConnection.SendMessageAsync(fileArtifactMessages).ConfigureAwait(false); break; } } + // Maps a session-scoped artifact to its wire message. Extracted so the producer-to-wire mapping + // (in particular the Kind carried for post-processing grouping) is unit-testable without a live pipe. + internal static FileArtifactMessages CreateFileArtifactMessages(string? executionId, SessionFileArtifact sessionFileArtifact) + => new( + executionId, + DotnetTestConnection.InstanceId, + [ + new FileArtifactMessage( + sessionFileArtifact.FileInfo.FullName, + sessionFileArtifact.DisplayName, + sessionFileArtifact.Description ?? string.Empty, + string.Empty, + string.Empty, + sessionFileArtifact.SessionUid.Value, + sessionFileArtifact.Kind) + ]); + + // Maps a (non session-scoped) artifact to its wire message. See the SessionFileArtifact overload. + internal static FileArtifactMessages CreateFileArtifactMessages(string? executionId, FileArtifact fileArtifact) + => new( + executionId, + DotnetTestConnection.InstanceId, + [ + new FileArtifactMessage( + fileArtifact.FileInfo.FullName, + fileArtifact.DisplayName, + fileArtifact.Description ?? string.Empty, + string.Empty, + string.Empty, + string.Empty, + fileArtifact.Kind) + ]); + private static TestNodeDetails? GetTestNodeDetails(TestNodeUpdateMessage testNodeUpdateMessage) { byte? state = null; diff --git a/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/Models/FileArtifactMessages.cs b/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/Models/FileArtifactMessages.cs index 4a0f5a52da..5db06f42ff 100644 --- a/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/Models/FileArtifactMessages.cs +++ b/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/Models/FileArtifactMessages.cs @@ -3,6 +3,6 @@ namespace Microsoft.Testing.Platform.IPC.Models; -internal sealed record FileArtifactMessage(string? FullPath, string? DisplayName, string? Description, string? TestUid, string? TestDisplayName, string? SessionUid); +internal sealed record FileArtifactMessage(string? FullPath, string? DisplayName, string? Description, string? TestUid, string? TestDisplayName, string? SessionUid, string? Kind); internal sealed record FileArtifactMessages(string? ExecutionId, string? InstanceId, FileArtifactMessage[] FileArtifacts) : IRequest; diff --git a/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/ObjectFieldIds.cs b/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/ObjectFieldIds.cs index f964693792..9abf6c1c70 100644 --- a/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/ObjectFieldIds.cs +++ b/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/ObjectFieldIds.cs @@ -150,6 +150,7 @@ internal static class FileArtifactMessageFieldsId public const ushort TestUid = 4; public const ushort TestDisplayName = 5; public const ushort SessionUid = 6; + public const ushort Kind = 7; } [Embedded] diff --git a/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/Serializers/FileArtifactMessagesSerializer.cs b/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/Serializers/FileArtifactMessagesSerializer.cs index 95e3742fca..71966552bf 100644 --- a/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/Serializers/FileArtifactMessagesSerializer.cs +++ b/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/Serializers/FileArtifactMessagesSerializer.cs @@ -46,6 +46,10 @@ namespace Microsoft.Testing.Platform.IPC.Serializers; |---FileArtifactMessageList[0].SessionUid Id---| (2 bytes) |---FileArtifactMessageList[0].SessionUid Size---| (4 bytes) |---FileArtifactMessageList[0].SessionUid Value---| (n bytes) + + |---FileArtifactMessageList[0].Kind Id---| (2 bytes) + |---FileArtifactMessageList[0].Kind Size---| (4 bytes) + |---FileArtifactMessageList[0].Kind Value---| (n bytes) */ internal sealed class FileArtifactMessagesSerializer : NamedPipeSerializer, INamedPipeSerializer @@ -84,7 +88,7 @@ private static FileArtifactMessage[] ReadFileArtifactMessagesPayload(Stream stre for (int i = 0; i < length; i++) { - string? fullPath = null, displayName = null, description = null, testUid = null, testDisplayName = null, sessionUid = null; + string? fullPath = null, displayName = null, description = null, testUid = null, testDisplayName = null, sessionUid = null, kind = null; ReadFields(stream, (fieldId, fieldSize) => { @@ -114,12 +118,16 @@ private static FileArtifactMessage[] ReadFileArtifactMessagesPayload(Stream stre sessionUid = ReadStringValue(stream, fieldSize); return true; + case FileArtifactMessageFieldsId.Kind: + kind = ReadStringValue(stream, fieldSize); + return true; + default: return false; } }); - fileArtifactMessages[i] = new FileArtifactMessage(fullPath, displayName, description, testUid, testDisplayName, sessionUid); + fileArtifactMessages[i] = new FileArtifactMessage(fullPath, displayName, description, testUid, testDisplayName, sessionUid, kind); } return fileArtifactMessages; @@ -147,6 +155,7 @@ private static void WriteFileArtifactMessagesPayload(Stream stream, FileArtifact WriteField(s, FileArtifactMessageFieldsId.TestUid, fileArtifactMessage.TestUid); WriteField(s, FileArtifactMessageFieldsId.TestDisplayName, fileArtifactMessage.TestDisplayName); WriteField(s, FileArtifactMessageFieldsId.SessionUid, fileArtifactMessage.SessionUid); + WriteField(s, FileArtifactMessageFieldsId.Kind, fileArtifactMessage.Kind); }); private static ushort GetFieldCount(FileArtifactMessage fileArtifactMessage) => @@ -155,5 +164,6 @@ private static ushort GetFieldCount(FileArtifactMessage fileArtifactMessage) => (fileArtifactMessage.Description is null ? 0 : 1) + (fileArtifactMessage.TestUid is null ? 0 : 1) + (fileArtifactMessage.TestDisplayName is null ? 0 : 1) + - (fileArtifactMessage.SessionUid is null ? 0 : 1)); + (fileArtifactMessage.SessionUid is null ? 0 : 1) + + (fileArtifactMessage.Kind is null ? 0 : 1)); } diff --git a/src/Platform/SharedExtensionHelpers/MergeOutputFileHelper.cs b/src/Platform/SharedExtensionHelpers/MergeOutputFileHelper.cs new file mode 100644 index 0000000000..3b52b5f752 --- /dev/null +++ b/src/Platform/SharedExtensionHelpers/MergeOutputFileHelper.cs @@ -0,0 +1,257 @@ +// 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; + +/// +/// Shared, security- and data-loss-sensitive filesystem helpers for report mergers (TRX/JUnit/CTRF). +/// Extracted so the read-only-input alias check and the atomic temporary-sibling write cannot diverge +/// across formats: RFC 018 treats per-module inputs as read-only, so a merge must never overwrite one of +/// its own sources. +/// +internal static class MergeOutputFileHelper +{ + /// + /// Rejects an output path that resolves to one of the input report paths. Paths are canonicalized + /// (symlinks resolved where the runtime supports it) and compared using the case sensitivity probed at + /// the output's own location, so a differently-cased or symlinked-parent output that aliases an input + /// is detected while a legitimately distinct output on a case-sensitive volume is not falsely rejected. + /// On runtimes that cannot resolve links, it fails closed when an output ancestor is a reparse point. + /// + internal static void EnsureOutputDoesNotAliasInput(IReadOnlyList inputPaths, string outputPath) + { +#if !NETCOREAPP + // This runtime cannot resolve symlinks/junctions, so a symlinked output parent directory that + // aliases an input directory would slip past the textual comparison below and let the write + // replace the input. Fail closed when any existing ancestor of the output is a reparse point. + if (HasReparsePointAncestor(outputPath)) + { + throw new ArgumentException($"The output path '{outputPath}' has a symbolic-link parent directory that cannot be safely resolved on this runtime; refusing to write to avoid overwriting a read-only input.", nameof(outputPath)); + } +#endif + string outputCanonical = GetCanonicalPath(outputPath); + StringComparison comparison = GetOutputPathComparison(outputPath); + foreach (string inputPath in inputPaths) + { + if (string.Equals(GetCanonicalPath(inputPath), outputCanonical, comparison)) + { + throw new ArgumentException($"The output path '{outputPath}' cannot be one of the input report paths; inputs are treated as read-only.", nameof(outputPath)); + } + } + } + + /// + /// Writes the merged report by invoking against a temporary sibling + /// of and then replacing the destination entry. If the output path is a + /// symlink/hardlink alias of an input (which the textual alias check cannot detect because + /// does not resolve links), replacing the entry removes only the + /// link and leaves the read-only source intact, rather than truncating it in place. + /// + internal static async Task WriteViaTemporarySiblingAsync(string outputPath, Func writeToTempAsync) + { + string tempPath = GetTempSiblingPath(outputPath); + try + { + await writeToTempAsync(tempPath).ConfigureAwait(false); + ReplaceFile(tempPath, outputPath); + } + finally + { + TryDeleteFile(tempPath); + } + } + + /// + /// Canonicalizes to a full path with symlinks/junctions resolved in every + /// existing component (so a symlinked parent directory that aliases another location is detected). On + /// runtimes without link resolution (netstandard/.NET Framework) it falls back to the lexical full + /// path. + /// + private static string GetCanonicalPath(string path) + { + string full = Path.GetFullPath(path); +#if NETCOREAPP + try + { + return ResolveSymlinks(full, remainingHops: 40); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException) + { + return full; + } +#else + return full; +#endif + } + +#if NETCOREAPP + // Resolves every symlink in 'full' to a stable canonical path — including symlinks in the ANCESTOR + // directories of a link's target — so two paths that name the same file compare equal regardless of + // which symlinked route reaches it. A single per-component pass is not enough: when a link's target + // string itself embeds another symlink (e.g. macOS '/var' -> '/private/var', so a link stored as + // '/var/.../real' resolves to a path whose '/var' prefix is still a link), the canonical form would + // otherwise depend on the route taken and the read-only-input alias check would miss an aliased output. + // Each time a link is followed we therefore recurse from the (absolute) target so its own ancestors are + // canonicalized too. 'remainingHops' bounds recursion so a symlink cycle degrades to the lexical path + // instead of looping forever. + private static string ResolveSymlinks(string full, int remainingHops) + { + string? root = Path.GetPathRoot(full); + if (remainingHops <= 0 || RoslynString.IsNullOrEmpty(root)) + { + return full; + } + + string[] parts = full.Substring(root.Length).Split([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], StringSplitOptions.RemoveEmptyEntries); + string resolved = root; + for (int i = 0; i < parts.Length; i++) + { + string next = Path.Combine(resolved, parts[i]); + FileSystemInfo? entry = Directory.Exists(next) + ? new DirectoryInfo(next) + : File.Exists(next) + ? new FileInfo(next) + : null; + + // Follow a single hop only; recursion re-canonicalizes the target's ancestors and any further + // links in the chain uniformly. + if (entry?.ResolveLinkTarget(returnFinalTarget: false)?.FullName is { } linkTarget) + { + string remainder = string.Join(Path.DirectorySeparatorChar.ToString(), parts, i + 1, parts.Length - (i + 1)); + string combined = remainder.Length == 0 ? linkTarget : Path.Combine(linkTarget, remainder); + return ResolveSymlinks(Path.GetFullPath(combined), remainingHops - 1); + } + + resolved = next; + } + + return resolved; + } +#endif + + // Whether two paths name the SAME file depends on the case sensitivity of the specific filesystem/ + // directory that will hold the output — which can differ by volume and, on Windows, per-directory — so + // this is probed at the output's OWN location (nearest existing ancestor) rather than a fixed temp dir. + // On a case-insensitive location 'a.trx' and 'A.trx' are the same file and must collide; on a + // case-sensitive one they are distinct, so a case-insensitive check would wrongly reject a legitimate + // separate output. An alias can only occur when the two paths share a directory, so that shared + // location's sensitivity is the correct one (a different-directory input never compares equal). + private static StringComparison GetOutputPathComparison(string outputPath) + { + string? probeDirectory = FindNearestExistingDirectory(outputPath); + return probeDirectory is not null && IsDirectoryCaseSensitive(probeDirectory) + ? StringComparison.Ordinal + : StringComparison.OrdinalIgnoreCase; + } + + private static string? FindNearestExistingDirectory(string path) + { + string? current = Path.GetDirectoryName(Path.GetFullPath(path)); + while (!RoslynString.IsNullOrEmpty(current)) + { + if (Directory.Exists(current)) + { + return current; + } + + string? parent = Path.GetDirectoryName(current); + if (parent is null || string.Equals(parent, current, StringComparison.Ordinal)) + { + break; + } + + current = parent; + } + + return current; + } + + private static bool IsDirectoryCaseSensitive(string directory) + { + try + { + string upper = Path.Combine(directory, "CASESENSITIVEPROBE" + Guid.NewGuid().ToString("N")); + using (new FileStream(upper, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None, 0x1000, FileOptions.DeleteOnClose)) + { + return !File.Exists(upper.ToLowerInvariant()); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) + { + // If the probe fails, assume case-insensitive-but-preserving (the safer, more conservative + // choice for the alias check: it rejects more, never less). + return false; + } + } + + private static string GetTempSiblingPath(string outputPath) + { + string directory = Path.GetDirectoryName(Path.GetFullPath(outputPath)) is { Length: > 0 } dir + ? dir + : Directory.GetCurrentDirectory(); + return Path.Combine(directory, Path.GetFileName(outputPath) + "." + Guid.NewGuid().ToString("N") + ".tmp"); + } + + private static void ReplaceFile(string tempPath, string outputPath) + { +#if NETCOREAPP + // Atomic replace. File.Move(overwrite: true) maps to rename(2) / MoveFileEx(REPLACE_EXISTING), + // which swaps the destination in a single step, so an interruption or competing writer can never + // leave the previous report permanently missing (the failure mode of a delete-then-move sequence). + // It also replaces a symlink or dangling-link ENTRY rather than following it — only the link is + // swapped, never its target's content — and an exact alias of an input has already been rejected, + // so this cannot truncate an input in place. + File.Move(tempPath, outputPath, overwrite: true); +#else + // .NET Framework's File.Move has no atomic-overwrite overload. Fall back to delete-then-move. On + // this runtime the alias check has already fail-closed on any reparse-point ancestor of the output, + // and File.Delete removes only a link entry (never a link target's content), so this cannot delete + // an input in place; deleting a dangling link (for which File.Exists is false yet the entry exists) + // also lets the subsequent File.Move succeed. + File.Delete(outputPath); + File.Move(tempPath, outputPath); +#endif + } + + private static void TryDeleteFile(string path) + { + try + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Best-effort temp cleanup: leaking a .tmp sibling is preferable to masking the primary + // exception (or the successful result) with a cleanup failure. + } + } + +#if !NETCOREAPP + private static bool HasReparsePointAncestor(string path) + { + string? current = Path.GetDirectoryName(Path.GetFullPath(path)); + while (!RoslynString.IsNullOrEmpty(current)) + { + if (Directory.Exists(current) && (File.GetAttributes(current) & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint) + { + return true; + } + + string? parent = Path.GetDirectoryName(current); + if (parent is null || string.Equals(parent, current, StringComparison.Ordinal)) + { + break; + } + + current = parent; + } + + return false; + } +#endif +} diff --git a/src/Platform/SharedExtensionHelpers/ReportGeneratorBase.cs b/src/Platform/SharedExtensionHelpers/ReportGeneratorBase.cs index f071fabff7..8db951b64b 100644 --- a/src/Platform/SharedExtensionHelpers/ReportGeneratorBase.cs +++ b/src/Platform/SharedExtensionHelpers/ReportGeneratorBase.cs @@ -174,7 +174,8 @@ await _messageBus.PublishAsync( testSessionContext.SessionUid, new FileInfo(reportFileName), ArtifactDisplayName, - ArtifactDescription)).ConfigureAwait(false); + ArtifactDescription, + ArtifactKind)).ConfigureAwait(false); } // Capture every update unconditionally — no UID-based deduplication. @@ -195,6 +196,14 @@ protected virtual void OnTestNodeUpdate(TestNodeUpdateMessage update) protected abstract string ArtifactDescription { get; } + /// + /// Gets the producer-asserted, reverse-DNS identifier of the artifact format this report + /// generator produces (e.g. microsoft.testing.junit). Used by post-processing to + /// group same-kind artifacts for consolidation. Returns by default + /// (no declared kind); report generators override to tag their output. + /// + protected virtual string? ArtifactKind => null; + protected abstract string GetGenerationLogMessage(int testResultCount); protected abstract TCapturedTestResult? TryCapture(TestNodeUpdateMessage update); diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/CtrfReportMergerTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/CtrfReportMergerTests.cs new file mode 100644 index 0000000000..b103983f64 --- /dev/null +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/CtrfReportMergerTests.cs @@ -0,0 +1,521 @@ +// 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.Nodes; + +using Microsoft.Testing.Extensions.CtrfReport; + +namespace Microsoft.Testing.Extensions.UnitTests; + +[TestClass] +public sealed class CtrfReportMergerTests +{ + [TestMethod] + public void Merge_WithNullReports_ThrowsArgumentNullException() + => Assert.ThrowsExactly(() => CtrfReportMerger.Merge(null!)); + + [TestMethod] + public void Merge_WithNoReports_ThrowsArgumentException() + => Assert.ThrowsExactly(() => CtrfReportMerger.Merge([])); + + [TestMethod] + public async Task MergeToFileAsync_WithNoInputs_ThrowsWithoutCreatingOutputDirectory() + { + string tempDirectory = Path.Combine(Path.GetTempPath(), $"ctrf-merge-{Guid.NewGuid():N}"); + try + { + string output = Path.Combine(tempDirectory, "out", "merged.json"); + await Assert.ThrowsExactlyAsync( + () => CtrfReportMerger.MergeToFileAsync([], output, CancellationToken.None)); + + Assert.IsFalse(Directory.Exists(tempDirectory)); + } + finally + { + if (Directory.Exists(tempDirectory)) + { + Directory.Delete(tempDirectory, recursive: true); + } + } + } + + [TestMethod] + public void Merge_ConcatenatesTests() + { + string a = BuildReport(testEntries: [Test("TestA", "passed"), Test("TestB", "failed")]); + string b = BuildReport(testEntries: [Test("TestC", "passed")]); + + JsonNode merged = JsonNode.Parse(CtrfReportMerger.Merge([a, b]))!; + + var testArray = (JsonArray)merged["results"]!["tests"]!; + Assert.HasCount(3, testArray); + List names = [.. testArray.Select(t => (string?)t!["name"])]; + Assert.Contains("TestA", names); + Assert.Contains("TestC", names); + } + + [TestMethod] + public void Merge_DerivesSummaryCountersFromTests() + { + string a = BuildReport(testEntries: [Test("a", "passed"), Test("b", "passed"), Test("c", "failed")]); + string b = BuildReport(testEntries: [Test("d", "passed"), Test("e", "skipped"), Test("f", "skipped"), Test("g", "skipped")]); + + JsonNode summary = JsonNode.Parse(CtrfReportMerger.Merge([a, b]))!["results"]!["summary"]!; + + Assert.AreEqual(7, (long)summary["tests"]!); + Assert.AreEqual(3, (long)summary["passed"]!); + Assert.AreEqual(1, (long)summary["failed"]!); + Assert.AreEqual(3, (long)summary["skipped"]!); + } + + [TestMethod] + public void Merge_DerivesSummaryFromTests_WhenInputSummaryMissing() + { + // An input that carries tests[] but no summary object must still contribute to the merged counts. + string withSummary = BuildReport(testEntries: [Test("a", "passed")]); + string withoutSummary = BuildReportWithoutSummary(Test("b", "failed"), Test("c", "skipped")); + + JsonNode summary = JsonNode.Parse(CtrfReportMerger.Merge([withSummary, withoutSummary]))!["results"]!["summary"]!; + + Assert.AreEqual(3, (long)summary["tests"]!); + Assert.AreEqual(1, (long)summary["passed"]!); + Assert.AreEqual(1, (long)summary["failed"]!); + Assert.AreEqual(1, (long)summary["skipped"]!); + } + + [TestMethod] + public void Merge_SummaryStartIsEarliestAndStopIsLatest() + { + string a = BuildReport(start: 2000, stop: 3000); + string b = BuildReport(start: 1000, stop: 5000); + + JsonNode summary = JsonNode.Parse(CtrfReportMerger.Merge([a, b]))!["results"]!["summary"]!; + + Assert.AreEqual(1000, (long)summary["start"]!); + Assert.AreEqual(5000, (long)summary["stop"]!); + Assert.AreEqual(4000, (long)summary["duration"]!); + } + + [TestMethod] + public void Merge_WhenInputsShareToolNameButDifferentVersion_UsesNeutralMergerToolIdentity() + { + // Same tool name but different version/metadata is still a distinct identity and must not be + // stamped onto every merged test. + string a = BuildReport(toolName: "MSTest", toolVersion: "1.0.0"); + string b = BuildReport(toolName: "MSTest", toolVersion: "2.0.0"); + + string? toolName = (string?)JsonNode.Parse(CtrfReportMerger.Merge([a, b]))!["results"]!["tool"]!["name"]; + + Assert.Contains("merged", toolName!); + } + + [TestMethod] + public void Merge_WhenOneInputMissingTool_UsesNeutralMergerToolIdentity() + { + string a = BuildReport(toolName: "MSTest"); + string b = BuildReportWithoutTool(); + + string? toolName = (string?)JsonNode.Parse(CtrfReportMerger.Merge([a, b]))!["results"]!["tool"]!["name"]; + + Assert.Contains("merged", toolName!); + } + + [TestMethod] + public void Merge_WhenAllInputsShareTool_KeepsThatTool() + { + string a = BuildReport(toolName: "MSTest"); + string b = BuildReport(toolName: "MSTest"); + + JsonNode merged = JsonNode.Parse(CtrfReportMerger.Merge([a, b]))!; + + Assert.AreEqual("CTRF", (string?)merged["reportFormat"]); + Assert.AreEqual("MSTest", (string?)merged["results"]!["tool"]!["name"]); + } + + [TestMethod] + public void Merge_WhenInputsUseDifferentTools_UsesNeutralMergerToolIdentity() + { + // Merging modules produced by different frameworks must not misattribute one framework's + // identity to another's tests, so a neutral merger identity is used instead of the first tool. + string a = BuildReport(toolName: "MSTest"); + string b = BuildReport(toolName: "OtherFramework"); + + string? toolName = (string?)JsonNode.Parse(CtrfReportMerger.Merge([a, b]))!["results"]!["tool"]!["name"]; + + Assert.AreNotEqual("MSTest", toolName); + Assert.AreNotEqual("OtherFramework", toolName); + Assert.Contains("merged", toolName!); + } + + [TestMethod] + public void Merge_DropsModuleSpecificEnvironmentExtraFields() + { + // testApplication/exitCode describe a single module and cannot describe all merged modules, so + // they must not be carried over (misattributing the first module's app/exit code to everyone). + string a = BuildReport(); + string b = BuildReport(); + + JsonNode? environmentExtra = JsonNode.Parse(CtrfReportMerger.Merge([a, b]))!["results"]!["environment"]!["extra"]; + + Assert.IsNotNull(environmentExtra); + Assert.IsNull(environmentExtra["testApplication"]); + Assert.IsNull(environmentExtra["exitCode"]); + // Shared, non-module-specific fields are retained. + Assert.AreEqual("someone", (string?)environmentExtra["user"]); + } + + [TestMethod] + public void Merge_DerivesDeterministicReportIdNotReusingInput() + { + string a = BuildReport(); + string b = BuildReport(); + + string? idA = (string?)JsonNode.Parse(a)!["reportId"]; + string? mergedId = (string?)JsonNode.Parse(CtrfReportMerger.Merge([a, b]))!["reportId"]; + string? mergedIdAgain = (string?)JsonNode.Parse(CtrfReportMerger.Merge([a, b]))!["reportId"]; + + Assert.IsNotNull(mergedId); + // Not one of the inputs' ids... + Assert.AreNotEqual(idA, mergedId); + // ...and deterministic: identical inputs reproduce the same id on every merge (RFC 018 idempotency). + Assert.AreEqual(mergedId, mergedIdAgain); + } + + [TestMethod] + public void Merge_ReportIdIsUnaffectedByIgnoredNonCtrfInput() + { + string a = BuildReport(testEntries: [Test("a", "passed")]); + string b = BuildReport(testEntries: [Test("b", "failed")]); + + // A non-CTRF input (missing the reportFormat discriminator) is skipped by the merge, so it must not + // participate in the deterministic reportId — the id must match the CTRF-only merge exactly. + string nonCtrf = "{\"results\":{\"summary\":{},\"tests\":[]}}"; + + string? ctrfOnlyId = (string?)JsonNode.Parse(CtrfReportMerger.Merge([a, b]))!["reportId"]; + string? withNoiseId = (string?)JsonNode.Parse(CtrfReportMerger.Merge([a, nonCtrf, b]))!["reportId"]; + + Assert.IsNotNull(ctrfOnlyId); + Assert.AreEqual(ctrfOnlyId, withNoiseId); + } + + [TestMethod] + public async Task MergeToFileAsync_WhenOutputAliasesAnInput_ThrowsArgumentException() + { + string tempDirectory = Path.Combine(Path.GetTempPath(), $"ctrf-merge-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDirectory); + try + { + string input = Path.Combine(tempDirectory, "a.json"); + File.WriteAllText(input, BuildReport()); + + // Overwriting an input would destroy a read-only source; it must be rejected. + await Assert.ThrowsExactlyAsync( + () => CtrfReportMerger.MergeToFileAsync([input], input, CancellationToken.None)); + + Assert.IsTrue(File.Exists(input)); + } + finally + { + Directory.Delete(tempDirectory, recursive: true); + } + } + + [TestMethod] + public async Task MergeToFileAsync_WritesMergedFileToDisk() + { + string tempDirectory = Path.Combine(Path.GetTempPath(), $"ctrf-merge-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDirectory); + try + { + string first = Path.Combine(tempDirectory, "a.json"); + string second = Path.Combine(tempDirectory, "b.json"); + string output = Path.Combine(tempDirectory, "nested", "merged.json"); + File.WriteAllText(first, BuildReport(testEntries: [Test("a", "passed"), Test("b", "passed")])); + File.WriteAllText(second, BuildReport(testEntries: [Test("c", "passed"), Test("d", "passed"), Test("e", "passed")])); + + await CtrfReportMerger.MergeToFileAsync([first, second], output, CancellationToken.None); + + Assert.IsTrue(File.Exists(output)); + JsonNode merged = JsonNode.Parse(File.ReadAllText(output))!; + Assert.AreEqual(5, (long)merged["results"]!["summary"]!["tests"]!); + } + finally + { + Directory.Delete(tempDirectory, recursive: true); + } + } + +#if NETCOREAPP + [TestMethod] + public async Task MergeToFileAsync_WhenOutputAliasesInputViaSymlinkedParent_ThrowsAndPreservesInput() + { + string tempDirectory = Path.Combine(Path.GetTempPath(), $"ctrf-merge-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDirectory); + try + { + string realDir = Path.Combine(tempDirectory, "real"); + Directory.CreateDirectory(realDir); + string input = Path.Combine(realDir, "a.json"); + File.WriteAllText(input, BuildReport()); + + string linkDir = Path.Combine(tempDirectory, "link"); + if (!TryCreateDirectorySymlink(linkDir, realDir)) + { + return; + } + + // Output goes through the symlinked parent, so it is the SAME physical file as the input. + string aliasedOutput = Path.Combine(linkDir, "a.json"); + await Assert.ThrowsExactlyAsync( + () => CtrfReportMerger.MergeToFileAsync([input], aliasedOutput, CancellationToken.None)); + + Assert.IsTrue(File.Exists(input)); + } + finally + { + Directory.Delete(tempDirectory, recursive: true); + } + } + + private static bool TryCreateDirectorySymlink(string linkPath, string targetPath) + { + try + { + Directory.CreateSymbolicLink(linkPath, targetPath); + return Directory.Exists(linkPath); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + return false; + } + } +#endif + + [TestMethod] + public void Merge_WhenEnvironmentsDiffer_RetainsCommonFieldsAndDropsDiffering() + { + // Two inputs from different CI agents disagree on osPlatform but share user/machine. The merged + // environment must drop the differing osPlatform, keep the common extra fields, and always drop + // the module-specific testApplication/exitCode. + string a = BuildReport(osPlatform: "linux"); + string b = BuildReport(osPlatform: "windows"); + + JsonNode environment = JsonNode.Parse(CtrfReportMerger.Merge([a, b]))!["results"]!["environment"]!; + + Assert.IsNull(environment["osPlatform"]); + var extra = (JsonObject)environment["extra"]!; + Assert.AreEqual("someone", (string?)extra["user"]); + Assert.AreEqual("box", (string?)extra["machine"]); + Assert.IsFalse(extra.ContainsKey("testApplication")); + Assert.IsFalse(extra.ContainsKey("exitCode")); + } + + [TestMethod] + public void Merge_WhenEnvironmentsMatch_RetainsSharedFields() + { + string a = BuildReport(osPlatform: "linux"); + string b = BuildReport(osPlatform: "linux"); + + JsonNode environment = JsonNode.Parse(CtrfReportMerger.Merge([a, b]))!["results"]!["environment"]!; + + Assert.AreEqual("linux", (string?)environment["osPlatform"]); + } + + [TestMethod] + public void Merge_StampsMergerIdentityInGeneratedBy() + { + // The merged document is produced by this merger, so 'generatedBy' must be the merger's identity, + // not the (possibly different-versioned) first input's value. + var report = new JsonObject + { + ["reportFormat"] = "CTRF", + ["specVersion"] = "0.0.0", + ["generatedBy"] = "SomeOtherTool v9", + ["results"] = new JsonObject + { + ["tool"] = new JsonObject { ["name"] = "MSTest" }, + ["tests"] = new JsonArray { Test("t", "passed") }, + }, + }; + + string generatedBy = (string)JsonNode.Parse(CtrfReportMerger.Merge([report.ToJsonString()]))!["generatedBy"]!; + + Assert.AreEqual("Microsoft.Testing.Extensions.CtrfReport", generatedBy); + } + + [TestMethod] + public void Merge_IgnoresNonCtrfInputs() + { + // A JSON object that is not a CTRF document must not be accepted (become 'first') and have + // CTRF-shaped data emitted under its label; its tests are excluded from the merge. This covers both + // a non-CTRF reportFormat and a missing reportFormat (the required format discriminator). + string ctrf = BuildReport(testEntries: [Test("a", "passed")]); + var wrongFormat = new JsonObject + { + ["reportFormat"] = "JUnit", + ["results"] = new JsonObject { ["tests"] = new JsonArray { Test("x", "passed") } }, + }; + var noFormat = new JsonObject + { + ["results"] = new JsonObject { ["tests"] = new JsonArray { Test("y", "passed") } }, + }; + + JsonNode merged = JsonNode.Parse(CtrfReportMerger.Merge([ctrf, wrongFormat.ToJsonString(), noFormat.ToJsonString()]))!; + + Assert.AreEqual("CTRF", (string?)merged["reportFormat"]); + Assert.AreEqual(1, (long)merged["results"]!["summary"]!["tests"]!); + } + + private static JsonObject Test(string name, string status) + => new() + { + ["name"] = name, + ["status"] = status, + ["duration"] = 1, + }; + + private static string BuildReport( + long tests = 1, + long passed = 1, + long failed = 0, + long skipped = 0, + long pending = 0, + long other = 0, + long flaky = 0, + long start = 1000, + long stop = 2000, + string toolName = "MSTest", + string? toolVersion = null, + string osPlatform = "test", + IEnumerable? testEntries = null) + { + var testArray = new JsonArray(); + foreach (JsonObject test in testEntries ?? [Test("DefaultTest", "passed")]) + { + testArray.Add(test); + } + + var toolObject = new JsonObject { ["name"] = toolName }; + if (toolVersion is not null) + { + toolObject["version"] = toolVersion; + } + + var report = new JsonObject + { + ["reportFormat"] = "CTRF", + ["specVersion"] = "0.0.0", + ["reportId"] = Guid.NewGuid().ToString("D"), + ["timestamp"] = DateTimeOffset.FromUnixTimeMilliseconds(stop).ToString("O", CultureInfo.InvariantCulture), + ["generatedBy"] = "Microsoft.Testing.Extensions.CtrfReport", + ["results"] = new JsonObject + { + ["tool"] = toolObject, + ["summary"] = new JsonObject + { + ["tests"] = tests, + ["passed"] = passed, + ["failed"] = failed, + ["skipped"] = skipped, + ["pending"] = pending, + ["other"] = other, + ["flaky"] = flaky, + ["start"] = start, + ["stop"] = stop, + ["duration"] = Math.Max(0, stop - start), + }, + ["environment"] = new JsonObject + { + ["osPlatform"] = osPlatform, + ["extra"] = new JsonObject + { + ["user"] = "someone", + ["machine"] = "box", + ["testApplication"] = "A.dll", + ["exitCode"] = 0, + }, + }, + ["tests"] = testArray, + }, + }; + + return report.ToJsonString(); + } + + [TestMethod] + public void Merge_WhenAnInputHasNoEnvironment_DropsEnvironment() + { + // One input supplies no environment at all. Its OS/user/machine are unknown, so no field is shared + // by every input and the merged report must not attribute the other input's environment to it. + string withEnvironment = BuildReport(osPlatform: "linux"); + string withoutEnvironment = BuildReportWithoutSummary(Test("t", "passed")); + + JsonNode results = JsonNode.Parse(CtrfReportMerger.Merge([withEnvironment, withoutEnvironment]))!["results"]!; + + Assert.IsNull(results["environment"]); + } + + private static string BuildReportWithoutSummary(params JsonObject[] testEntries) + { + var testArray = new JsonArray(); + foreach (JsonObject test in testEntries) + { + testArray.Add(test); + } + + var report = new JsonObject + { + ["reportFormat"] = "CTRF", + ["specVersion"] = "0.0.0", + ["reportId"] = Guid.NewGuid().ToString("D"), + ["results"] = new JsonObject + { + ["tool"] = new JsonObject { ["name"] = "MSTest" }, + ["tests"] = testArray, + }, + }; + + return report.ToJsonString(); + } + + [TestMethod] + public void Merge_UsesTestLevelTimingWhenSummaryMissing() + { + // A summary-less input still carries per-test start/stop; those must feed the merged min/max + // rather than being dropped (which would make the merged window fall back to the epoch). + string withSummary = BuildReport(start: 5000, stop: 6000); + string withoutSummary = BuildReportWithoutSummary(TimedTest("t", 1000, 9000)); + + JsonNode summary = JsonNode.Parse(CtrfReportMerger.Merge([withSummary, withoutSummary]))!["results"]!["summary"]!; + + Assert.AreEqual(1000, (long)summary["start"]!); + Assert.AreEqual(9000, (long)summary["stop"]!); + } + + private static JsonObject TimedTest(string name, long start, long stop) + => new() + { + ["name"] = name, + ["status"] = "passed", + ["duration"] = stop - start, + ["start"] = start, + ["stop"] = stop, + }; + + private static string BuildReportWithoutTool() + { + var report = new JsonObject + { + ["reportFormat"] = "CTRF", + ["specVersion"] = "0.0.0", + ["reportId"] = Guid.NewGuid().ToString("D"), + ["results"] = new JsonObject + { + ["summary"] = new JsonObject { ["tests"] = 1, ["passed"] = 1, ["start"] = 1000, ["stop"] = 2000 }, + ["tests"] = new JsonArray(Test("t", "passed")), + }, + }; + + return report.ToJsonString(); + } +} diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/JUnitReportMergerTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/JUnitReportMergerTests.cs new file mode 100644 index 0000000000..9ca3fa8e3c --- /dev/null +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/JUnitReportMergerTests.cs @@ -0,0 +1,256 @@ +// 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.Extensions.JUnitReport; + +namespace Microsoft.Testing.Extensions.UnitTests; + +[TestClass] +public sealed class JUnitReportMergerTests +{ + [TestMethod] + public void Merge_WithNullReports_ThrowsArgumentNullException() + => Assert.ThrowsExactly(() => JUnitReportMerger.Merge(null!, "run")); + + [TestMethod] + public void Merge_WithNoReports_ThrowsArgumentException() + => Assert.ThrowsExactly(() => JUnitReportMerger.Merge([], "run")); + + [TestMethod] + public async Task MergeToFileAsync_WithNoInputs_ThrowsWithoutCreatingOutputDirectory() + { + string tempDirectory = Path.Combine(Path.GetTempPath(), $"junit-merge-{Guid.NewGuid():N}"); + try + { + string output = Path.Combine(tempDirectory, "out", "merged.xml"); + await Assert.ThrowsExactlyAsync( + () => JUnitReportMerger.MergeToFileAsync([], output, "run", CancellationToken.None)); + + Assert.IsFalse(Directory.Exists(tempDirectory)); + } + finally + { + if (Directory.Exists(tempDirectory)) + { + Directory.Delete(tempDirectory, recursive: true); + } + } + } + + [TestMethod] + public void Merge_SumsRootCounters() + { + XDocument a = BuildReport(tests: 3, failures: 1, errors: 0, skipped: 1, time: 1.5); + XDocument b = BuildReport(tests: 4, failures: 0, errors: 2, skipped: 0, time: 2.25); + + XElement root = JUnitReportMerger.Merge([a, b], "run").Root!; + + Assert.AreEqual("7", root.Attribute("tests")!.Value); + Assert.AreEqual("1", root.Attribute("failures")!.Value); + Assert.AreEqual("2", root.Attribute("errors")!.Value); + Assert.AreEqual("1", root.Attribute("skipped")!.Value); + Assert.AreEqual("3.750", root.Attribute("time")!.Value); + } + + [TestMethod] + public void Merge_UnionsSuitesAndReassignsSequentialIds() + { + XDocument a = BuildReport(suites: [Suite("SuiteA"), Suite("SuiteB")]); + XDocument b = BuildReport(suites: [Suite("SuiteC")]); + + XElement root = JUnitReportMerger.Merge([a, b], "run").Root!; + + List suites = [.. root.Elements().Where(e => e.Name.LocalName == "testsuite")]; + Assert.HasCount(3, suites); + List ids = [.. suites.Select(s => s.Attribute("id")!.Value)]; + Assert.AreSequenceEqual(new[] { "0", "1", "2" }, ids); + List names = [.. suites.Select(s => s.Attribute("name")!.Value)]; + Assert.Contains("SuiteA", names); + Assert.Contains("SuiteC", names); + } + + [TestMethod] + public void Merge_SetsProvidedReportName() + { + XElement root = JUnitReportMerger.Merge([BuildReport(), BuildReport()], "my-merged-run").Root!; + + Assert.AreEqual("testsuites", root.Name.LocalName); + Assert.AreEqual("my-merged-run", root.Attribute("name")!.Value); + } + + [TestMethod] + public void Merge_TimestampIsEarliestAcrossInputs() + { + XDocument a = BuildReport(timestamp: new DateTimeOffset(2020, 1, 1, 12, 0, 0, TimeSpan.Zero)); + XDocument b = BuildReport(timestamp: new DateTimeOffset(2020, 1, 1, 9, 0, 0, TimeSpan.Zero)); + + XElement root = JUnitReportMerger.Merge([a, b], "run").Root!; + + Assert.AreEqual("2020-01-01T09:00:00.000", root.Attribute("timestamp")!.Value); + } + + [TestMethod] + public void Merge_SupportsBareTestSuiteRootedInput() + { + // A document rooted at a bare (rather than ) is a valid JUnit shape + // and must not be silently dropped. + var bareSuite = new XDocument(Suite("BareSuite", tests: 2, failures: 1)); + XDocument normal = BuildReport(tests: 3); + + XElement root = JUnitReportMerger.Merge([bareSuite, normal], "run").Root!; + + Assert.HasCount(2, root.Elements().Where(e => e.Name.LocalName == "testsuite")); + Assert.AreEqual("5", root.Attribute("tests")!.Value); + Assert.AreEqual("1", root.Attribute("failures")!.Value); + } + + [TestMethod] + public void Merge_DerivesCountersFromSuitesWhenRootAggregatesMissing() + { + // Root carries no aggregate attributes; totals must still come from the child suites. + var rootWithoutAggregates = new XDocument(new XElement("testsuites", new XAttribute("name", "asm"), Suite("S1", tests: 4, skipped: 2))); + XDocument normal = BuildReport(tests: 1); + + XElement root = JUnitReportMerger.Merge([rootWithoutAggregates, normal], "run").Root!; + + Assert.AreEqual("5", root.Attribute("tests")!.Value); + Assert.AreEqual("2", root.Attribute("skipped")!.Value); + } + + [TestMethod] + public async Task MergeToFileAsync_WritesMergedFileToDisk() + { + string tempDirectory = Path.Combine(Path.GetTempPath(), $"junit-merge-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDirectory); + try + { + string first = Path.Combine(tempDirectory, "a.xml"); + string second = Path.Combine(tempDirectory, "b.xml"); + string output = Path.Combine(tempDirectory, "nested", "merged.xml"); + BuildReport(tests: 2).Save(first); + BuildReport(tests: 3).Save(second); + + await JUnitReportMerger.MergeToFileAsync([first, second], output, "run", CancellationToken.None); + + Assert.IsTrue(File.Exists(output)); + var merged = XDocument.Load(output); + Assert.AreEqual("5", merged.Root!.Attribute("tests")!.Value); + } + finally + { + Directory.Delete(tempDirectory, recursive: true); + } + } + + [TestMethod] + public async Task MergeToFileAsync_WhenOutputAliasesAnInput_ThrowsArgumentException() + { + string tempDirectory = Path.Combine(Path.GetTempPath(), $"junit-merge-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDirectory); + try + { + string input = Path.Combine(tempDirectory, "a.xml"); + BuildReport(tests: 2).Save(input); + + // Overwriting an input would destroy a read-only source; it must be rejected. + await Assert.ThrowsExactlyAsync( + () => JUnitReportMerger.MergeToFileAsync([input], input, "run", CancellationToken.None)); + + Assert.IsTrue(File.Exists(input)); + } + finally + { + Directory.Delete(tempDirectory, recursive: true); + } + } + +#if NETCOREAPP + [TestMethod] + public async Task MergeToFileAsync_WhenOutputAliasesInputViaSymlinkedParent_ThrowsAndPreservesInput() + { + string tempDirectory = Path.Combine(Path.GetTempPath(), $"junit-merge-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDirectory); + try + { + string realDir = Path.Combine(tempDirectory, "real"); + Directory.CreateDirectory(realDir); + string input = Path.Combine(realDir, "a.xml"); + BuildReport(tests: 2).Save(input); + + string linkDir = Path.Combine(tempDirectory, "link"); + if (!TryCreateDirectorySymlink(linkDir, realDir)) + { + return; + } + + // Output goes through the symlinked parent, so it is the SAME physical file as the input. + string aliasedOutput = Path.Combine(linkDir, "a.xml"); + await Assert.ThrowsExactlyAsync( + () => JUnitReportMerger.MergeToFileAsync([input], aliasedOutput, "run", CancellationToken.None)); + + Assert.IsTrue(File.Exists(input)); + } + finally + { + Directory.Delete(tempDirectory, recursive: true); + } + } + + private static bool TryCreateDirectorySymlink(string linkPath, string targetPath) + { + try + { + Directory.CreateSymbolicLink(linkPath, targetPath); + return Directory.Exists(linkPath); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + return false; + } + } +#endif + + private static XElement Suite( + string name, + long tests = 1, + long failures = 0, + long errors = 0, + long skipped = 0, + double time = 0, + DateTimeOffset? timestamp = null) + => new( + "testsuite", + new XAttribute("name", name), + new XAttribute("tests", tests), + new XAttribute("failures", failures), + new XAttribute("errors", errors), + new XAttribute("skipped", skipped), + new XAttribute("time", time.ToString("0.000", CultureInfo.InvariantCulture)), + new XAttribute("timestamp", (timestamp ?? new DateTimeOffset(2020, 1, 1, 10, 0, 0, TimeSpan.Zero)).UtcDateTime.ToString("yyyy-MM-ddTHH:mm:ss.fff", CultureInfo.InvariantCulture)), + new XElement("testcase", new XAttribute("name", $"{name}.Test1"), new XAttribute("classname", name))); + + private static XDocument BuildReport( + long tests = 1, + long failures = 0, + long errors = 0, + long skipped = 0, + double time = 0, + DateTimeOffset? timestamp = null, + IEnumerable? suites = null) + { + // The merged aggregates are derived from the per-suite counters, so the default suite carries + // the report's counters/timestamp (the root aggregates are illustrative only). + var root = new XElement( + "testsuites", + new XAttribute("name", "assembly"), + new XAttribute("tests", tests), + new XAttribute("failures", failures), + new XAttribute("errors", errors), + new XAttribute("skipped", skipped), + new XAttribute("time", time.ToString("0.000", CultureInfo.InvariantCulture)), + new XAttribute("timestamp", (timestamp ?? new DateTimeOffset(2020, 1, 1, 10, 0, 0, TimeSpan.Zero)).UtcDateTime.ToString("yyyy-MM-ddTHH:mm:ss.fff", CultureInfo.InvariantCulture)), + suites ?? [Suite("DefaultSuite", tests, failures, errors, skipped, time, timestamp)]); + + return new XDocument(root); + } +} diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs new file mode 100644 index 0000000000..1b92d04fec --- /dev/null +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs @@ -0,0 +1,1249 @@ +// 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.Extensions.TrxReport.Abstractions; + +namespace Microsoft.Testing.Extensions.UnitTests; + +[TestClass] +public sealed class TrxReportEngineMergeTests +{ + private static readonly XNamespace Ns = "http://microsoft.com/schemas/VisualStudio/TeamTest/2010"; + + [TestMethod] + public void Merge_WithNullReports_ThrowsArgumentNullException() + => Assert.ThrowsExactly(() => TrxReportEngine.Merge(null!, Guid.NewGuid(), "run")); + + [TestMethod] + public void Merge_WithNoReports_ThrowsArgumentException() + => Assert.ThrowsExactly(() => TrxReportEngine.Merge([], Guid.NewGuid(), "run")); + + [TestMethod] + public async Task MergeToFileAsync_WithNoInputs_ThrowsWithoutTouchingFilesystem() + { + string tempDirectory = Path.Combine(Path.GetTempPath(), $"trx-merge-{Guid.NewGuid():N}"); + try + { + // An empty input list must be rejected before any filesystem work — the output directory must + // not be created for an invalid call. + string output = Path.Combine(tempDirectory, "out", "merged.trx"); + await Assert.ThrowsExactlyAsync( + () => TrxReportEngine.MergeToFileAsync([], output, Guid.NewGuid(), "run", CancellationToken.None)); + + Assert.IsFalse(Directory.Exists(tempDirectory)); + } + finally + { + if (Directory.Exists(tempDirectory)) + { + Directory.Delete(tempDirectory, recursive: true); + } + } + } + + [TestMethod] + public void Merge_CarriesRunLevelOutputMessages() + { + // VSTest records run-level skipped/informational messages under ResultSummary/Output/TextMessages; + // the merge must carry them across (in schema order, right after Counters) rather than dropping them. + var outputA = new XElement( + Ns + "Output", + new XElement(Ns + "StdOut", "hello from a"), + new XElement(Ns + "TextMessages", new XElement(Ns + "Message", "skipped test X"))); + var outputB = new XElement( + Ns + "Output", + new XElement(Ns + "TextMessages", new XElement(Ns + "Message", "informational Y"))); + XDocument a = BuildReport(resultSummaryChildren: [outputA]); + XDocument b = BuildReport(resultSummaryChildren: [outputB]); + + XElement summary = ResultSummary(TrxReportEngine.Merge([a, b], Guid.NewGuid(), "run")); + + // Output must appear immediately after Counters (schema order). + List childOrder = [.. summary.Elements().Select(e => e.Name.LocalName)]; + Assert.AreEqual("Counters", childOrder[0]); + Assert.AreEqual("Output", childOrder[1]); + + XElement output = summary.Elements().First(e => e.Name.LocalName == "Output"); + Assert.AreEqual("hello from a", output.Elements().First(e => e.Name.LocalName == "StdOut").Value); + List messages = [.. output.Descendants().Where(e => e.Name.LocalName == "Message").Select(e => e.Value)]; + Assert.Contains("skipped test X", messages); + Assert.Contains("informational Y", messages); + } + + [TestMethod] + public void Merge_SetsProvidedRunIdAndName() + { + var runId = Guid.NewGuid(); + + XDocument merged = TrxReportEngine.Merge([BuildReport(), BuildReport()], runId, "my-merged-run"); + + XElement testRun = merged.Root!; + Assert.AreEqual("TestRun", testRun.Name.LocalName); + Assert.AreEqual(runId.ToString(), testRun.Attribute("id")!.Value); + Assert.AreEqual("my-merged-run", testRun.Attribute("name")!.Value); + } + + [TestMethod] + public void Merge_SumsCounters() + { + XDocument a = BuildReport(total: 3, passed: 2, failed: 1, notExecuted: 0, timeout: 0); + XDocument b = BuildReport(total: 4, passed: 1, failed: 0, notExecuted: 3, timeout: 0); + + XElement counters = Counters(TrxReportEngine.Merge([a, b], Guid.NewGuid(), "run")); + + Assert.AreEqual("7", counters.Attribute("total")!.Value); + Assert.AreEqual("3", counters.Attribute("passed")!.Value); + Assert.AreEqual("1", counters.Attribute("failed")!.Value); + Assert.AreEqual("3", counters.Attribute("notExecuted")!.Value); + } + + [TestMethod] + public void Merge_WithAllCompletedReports_OutcomeIsCompleted() + { + XDocument merged = TrxReportEngine.Merge( + [BuildReport(outcome: "Completed"), BuildReport(outcome: "Completed")], + Guid.NewGuid(), + "run"); + + Assert.AreEqual("Completed", ResultSummary(merged).Attribute("outcome")!.Value); + } + + [TestMethod] + public void Merge_WhenAnyReportFailed_OutcomeIsFailed() + { + XDocument merged = TrxReportEngine.Merge( + [BuildReport(outcome: "Completed"), BuildReport(outcome: "Failed")], + Guid.NewGuid(), + "run"); + + Assert.AreEqual("Failed", ResultSummary(merged).Attribute("outcome")!.Value); + } + + [TestMethod] + public void Merge_WhenFailedCounterIsPositive_OutcomeIsFailed() + { + // Both reports claim "Completed" in the summary, but one has a positive failed counter. + XDocument merged = TrxReportEngine.Merge( + [BuildReport(outcome: "Completed", total: 1, passed: 1), BuildReport(outcome: "Completed", total: 1, passed: 0, failed: 1)], + Guid.NewGuid(), + "run"); + + Assert.AreEqual("Failed", ResultSummary(merged).Attribute("outcome")!.Value); + } + + [TestMethod] + public void Merge_UnionsResults() + { + XDocument a = BuildReport(results: [Result("e1", "t1", "TestA")]); + XDocument b = BuildReport(results: [Result("e2", "t2", "TestB"), Result("e3", "t3", "TestC")]); + + XElement results = Child(TrxReportEngine.Merge([a, b], Guid.NewGuid(), "run").Root!, "Results"); + + List merged = [.. results.Elements()]; + Assert.HasCount(3, merged); + List testNames = [.. merged.Select(e => e.Attribute("testName")!.Value)]; + Assert.Contains("TestA", testNames); + Assert.Contains("TestC", testNames); + } + + [TestMethod] + public void Merge_UnionsTestDefinitionsAndEntries() + { + XElement def = new(Ns + "UnitTest", new XAttribute("id", "t1"), new XAttribute("name", "TestA")); + XElement entry = new(Ns + "TestEntry", new XAttribute("testId", "t1"), new XAttribute("executionId", "e1")); + XDocument a = BuildReport(testDefinitions: [def], testEntries: [entry]); + XDocument b = BuildReport( + testDefinitions: [new XElement(Ns + "UnitTest", new XAttribute("id", "t2"), new XAttribute("name", "TestB"))], + testEntries: [new XElement(Ns + "TestEntry", new XAttribute("testId", "t2"), new XAttribute("executionId", "e2"))]); + + XElement root = TrxReportEngine.Merge([a, b], Guid.NewGuid(), "run").Root!; + + Assert.HasCount(2, Child(root, "TestDefinitions").Elements()); + Assert.HasCount(2, Child(root, "TestEntries").Elements()); + } + + [TestMethod] + public void Merge_WhenSameIdDefinitionsDiffer_RemapsAndRewritesReferences() + { + // A multi-TFM merge can produce two TestDefinitions that share an id but differ (e.g. different + // storage). The merge must keep both — remapping the later one to a fresh id and rewriting its + // results/entries — rather than dropping the module-specific definition. + XElement defA = new(Ns + "UnitTest", new XAttribute("id", "t1"), new XAttribute("storage", "a.dll"), new XAttribute("name", "SharedTest")); + XElement defB = new(Ns + "UnitTest", new XAttribute("id", "t1"), new XAttribute("storage", "b.dll"), new XAttribute("name", "SharedTest")); + XDocument a = BuildReport( + testDefinitions: [defA], + results: [new XElement(Ns + "UnitTestResult", new XAttribute("testId", "t1"), new XAttribute("executionId", "e1"))], + testEntries: [new XElement(Ns + "TestEntry", new XAttribute("testId", "t1"), new XAttribute("executionId", "e1"))]); + XDocument b = BuildReport( + testDefinitions: [defB], + results: [new XElement(Ns + "UnitTestResult", new XAttribute("testId", "t1"), new XAttribute("executionId", "e2"))], + testEntries: [new XElement(Ns + "TestEntry", new XAttribute("testId", "t1"), new XAttribute("executionId", "e2"))]); + + XElement root = TrxReportEngine.Merge([a, b], Guid.NewGuid(), "run").Root!; + + // Both definitions survive with distinct ids and both storages are present. + List definitions = [.. Child(root, "TestDefinitions").Elements()]; + Assert.HasCount(2, definitions); + List ids = [.. definitions.Select(e => e.Attribute("id")!.Value)]; + Assert.HasCount(2, ids.Distinct()); + List storages = [.. definitions.Select(e => e.Attribute("storage")!.Value)]; + Assert.Contains("a.dll", storages); + Assert.Contains("b.dll", storages); + + // Every result/entry testId must reference a real definition id, and — critically — each + // execution must map to the definition with ITS OWN storage: e1 -> a.dll, e2 -> b.dll. This fails + // if the remap did not rewrite the second input's references (both would resolve to a.dll). + var storageByDefinitionId = definitions.ToDictionary(e => e.Attribute("id")!.Value, e => e.Attribute("storage")!.Value); + + string StorageForExecution(string containerName, string executionId) + { + XElement element = Child(root, containerName).Elements() + .First(e => e.Attribute("executionId")!.Value == executionId); + return storageByDefinitionId[element.Attribute("testId")!.Value]; + } + + Assert.AreEqual("a.dll", StorageForExecution("Results", "e1")); + Assert.AreEqual("b.dll", StorageForExecution("Results", "e2")); + Assert.AreEqual("a.dll", StorageForExecution("TestEntries", "e1")); + Assert.AreEqual("b.dll", StorageForExecution("TestEntries", "e2")); + } + + [TestMethod] + public void Merge_CarriesRunLevelResultFiles() + { + // VSTest-produced TRX stores run-level attachments under ResultSummary/ResultFiles; the merge must + // carry them across rather than silently losing them (only RunInfos/CollectorDataEntries were kept). + var resultFiles = new XElement(Ns + "ResultFiles", new XElement(Ns + "ResultFile", new XAttribute("path", "run/summary.txt"))); + XDocument a = BuildReport(resultSummaryChildren: [resultFiles]); + + XElement summary = ResultSummary(TrxReportEngine.Merge([a, BuildReport()], Guid.NewGuid(), "run")); + + XElement? mergedResultFiles = summary.Elements().FirstOrDefault(e => e.Name.LocalName == "ResultFiles"); + Assert.IsNotNull(mergedResultFiles); + Assert.HasCount(1, mergedResultFiles.Elements()); + Assert.AreEqual("run/summary.txt", mergedResultFiles.Elements().First().Attribute("path")!.Value); + } + + [TestMethod] + public async Task MergeToFileAsync_WhenSourceMissing_DropsRelativeButPreservesRootedReferences() + { + string tempDirectory = Path.Combine(Path.GetTempPath(), $"trx-merge-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDirectory); + try + { + // The deployment 'In' root does not exist, so relocation is abandoned for this input. Its + // RELATIVE references must be dropped (they would dangle against the merged root), but a ROOTED + // reference resolves independently of the deployment root and must be preserved (RFC 018). + string inputDir = Path.Combine(tempDirectory, "in"); + Directory.CreateDirectory(inputDir); + string rootedHref = Path.Combine(tempDirectory, "abs", "kept.txt"); + + var collectorDataEntries = new XElement( + Ns + "CollectorDataEntries", + new XElement( + Ns + "Collector", + new XAttribute("collectorDisplayName", "Code Coverage"), + new XElement( + Ns + "UriAttachments", + new XElement(Ns + "UriAttachment", new XElement(Ns + "A", new XAttribute("href", rootedHref))), + new XElement(Ns + "UriAttachment", new XElement(Ns + "A", new XAttribute("href", "machine/log.txt")))))); + + XDocument report = BuildReport(resultSummaryChildren: [collectorDataEntries]); + report.Root!.Add(new XElement( + Ns + "TestSettings", + new XAttribute("name", "default"), + new XElement(Ns + "Deployment", new XAttribute("runDeploymentRoot", "dep")))); + + string input = Path.Combine(inputDir, "a.trx"); + report.Save(input); + string output = Path.Combine(tempDirectory, "out", "merged.trx"); + + await TrxReportEngine.MergeToFileAsync([input], output, Guid.NewGuid(), "run", CancellationToken.None); + + List hrefs = [.. XDocument.Load(output).Descendants().Where(e => e.Name.LocalName == "A").Select(e => e.Attribute("href")!.Value)]; + Assert.HasCount(1, hrefs); + Assert.AreEqual(rootedHref, hrefs[0]); + } + finally + { + Directory.Delete(tempDirectory, recursive: true); + } + } + + [TestMethod] + public void Merge_WhenAnInputHasUnsuccessfulOutcome_MergedOutcomeIsFailed() + { + // A TRX summary outcome of "Error" (not just "Failed") is an unsuccessful run and must not be + // flattened to "Completed" in the merged report. + XDocument a = BuildReport(outcome: "Completed"); + XDocument b = BuildReport(outcome: "Error"); + + XElement summary = ResultSummary(TrxReportEngine.Merge([a, b], Guid.NewGuid(), "run")); + + Assert.AreEqual("Failed", summary.Attribute("outcome")!.Value); + } + + [TestMethod] + public void Merge_DeduplicatesTestDefinitionsById() + { + // The same test discovered in two inputs yields the same deterministic UnitTest id; the + // merged report must not emit duplicate . + XElement sharedDef = new(Ns + "UnitTest", new XAttribute("id", "t1"), new XAttribute("name", "SharedTest")); + XDocument a = BuildReport(testDefinitions: [new XElement(sharedDef)]); + XDocument b = BuildReport( + testDefinitions: + [ + new XElement(sharedDef), + new XElement(Ns + "UnitTest", new XAttribute("id", "t2"), new XAttribute("name", "OtherTest")), + ]); + + XElement definitions = Child(TrxReportEngine.Merge([a, b], Guid.NewGuid(), "run").Root!, "TestDefinitions"); + + List ids = [.. definitions.Elements().Select(e => e.Attribute("id")!.Value)]; + Assert.HasCount(2, ids); + Assert.HasCount(2, ids.Distinct()); + } + + [TestMethod] + public void Merge_PreservesRunInfosFromInputs() + { + XElement runInfos = new( + Ns + "RunInfos", + new XElement(Ns + "RunInfo", new XAttribute("outcome", "Error"), new XElement(Ns + "Text", "host crashed"))); + XDocument a = BuildReport(resultSummaryChildren: [runInfos]); + XDocument b = BuildReport(); + + XElement summary = ResultSummary(TrxReportEngine.Merge([a, b], Guid.NewGuid(), "run")); + + XElement? mergedRunInfos = summary.Elements().FirstOrDefault(e => e.Name.LocalName == "RunInfos"); + Assert.IsNotNull(mergedRunInfos); + Assert.Contains("host crashed", mergedRunInfos!.Value); + } + + [TestMethod] + public void Merge_PreservesCollectorDataEntriesFromInputs() + { + XElement entries = new( + Ns + "CollectorDataEntries", + new XElement(Ns + "Collector", new XAttribute("collectorDisplayName", "Code Coverage"))); + XDocument a = BuildReport(resultSummaryChildren: [entries]); + XDocument b = BuildReport(); + + XElement summary = ResultSummary(TrxReportEngine.Merge([a, b], Guid.NewGuid(), "run")); + + XElement? mergedEntries = summary.Elements().FirstOrDefault(e => e.Name.LocalName == "CollectorDataEntries"); + Assert.IsNotNull(mergedEntries); + Assert.HasCount(1, mergedEntries.Elements().Where(e => e.Name.LocalName == "Collector")); + } + + [TestMethod] + public void Merge_WhenNoDiagnostics_OmitsEmptyRunInfosAndCollectorDataEntries() + { + XElement summary = ResultSummary(TrxReportEngine.Merge([BuildReport(), BuildReport()], Guid.NewGuid(), "run")); + + List childNames = [.. summary.Elements().Select(e => e.Name.LocalName)]; + Assert.DoesNotContain("RunInfos", childNames); + Assert.DoesNotContain("CollectorDataEntries", childNames); + } + + [TestMethod] + public void Merge_DeduplicatesTestListsById() + { + // Both reports carry the two well-known shared test lists; the merged output keeps each id once. + XDocument a = BuildReport(testLists: DefaultTestLists()); + XDocument b = BuildReport(testLists: [.. DefaultTestLists(), TestList("11111111-1111-1111-1111-111111111111", "Extra")]); + + XElement testLists = Child(TrxReportEngine.Merge([a, b], Guid.NewGuid(), "run").Root!, "TestLists"); + + List ids = [.. testLists.Elements().Select(e => e.Attribute("id")!.Value)]; + Assert.HasCount(3, ids); + Assert.HasCount(3, ids.Distinct()); + } + + [TestMethod] + public void Merge_TimesUseEarliestStartAndLatestFinish() + { + var earlyStart = new DateTimeOffset(2020, 1, 1, 9, 0, 0, TimeSpan.Zero); + var lateFinish = new DateTimeOffset(2020, 1, 1, 13, 0, 0, TimeSpan.Zero); + XDocument a = BuildReport(start: new DateTimeOffset(2020, 1, 1, 10, 0, 0, TimeSpan.Zero), finish: new DateTimeOffset(2020, 1, 1, 11, 0, 0, TimeSpan.Zero)); + XDocument b = BuildReport(start: earlyStart, finish: lateFinish); + + XElement times = Child(TrxReportEngine.Merge([a, b], Guid.NewGuid(), "run").Root!, "Times"); + + Assert.AreEqual(earlyStart, DateTimeOffset.Parse(times.Attribute("start")!.Value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind)); + Assert.AreEqual(lateFinish, DateTimeOffset.Parse(times.Attribute("finish")!.Value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind)); + } + + [TestMethod] + public void Merge_TimesTrackEarliestCreationQueuingStartIndependently() + { + // Each Times attribute is tracked from its own inputs, not fabricated from the start: the merged + // report keeps the earliest creation, earliest queuing and earliest start even when they come from + // different inputs (creation and queuing legitimately predate execution). + XDocument a = BuildTimesReport(creation: "2020-01-01T08:00:00.0000000+00:00", queuing: "2020-01-01T08:30:00.0000000+00:00", start: "2020-01-01T10:00:00.0000000+00:00", finish: "2020-01-01T11:00:00.0000000+00:00"); + XDocument b = BuildTimesReport(creation: "2020-01-01T09:00:00.0000000+00:00", queuing: "2020-01-01T08:15:00.0000000+00:00", start: "2020-01-01T09:30:00.0000000+00:00", finish: "2020-01-01T12:00:00.0000000+00:00"); + + XElement times = Child(TrxReportEngine.Merge([a, b], Guid.NewGuid(), "run").Root!, "Times"); + + Assert.AreEqual(DateTimeOffset.Parse("2020-01-01T08:00:00+00:00", CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind), DateTimeOffset.Parse(times.Attribute("creation")!.Value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind)); + Assert.AreEqual(DateTimeOffset.Parse("2020-01-01T08:15:00+00:00", CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind), DateTimeOffset.Parse(times.Attribute("queuing")!.Value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind)); + Assert.AreEqual(DateTimeOffset.Parse("2020-01-01T09:30:00+00:00", CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind), DateTimeOffset.Parse(times.Attribute("start")!.Value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind)); + Assert.AreEqual(DateTimeOffset.Parse("2020-01-01T12:00:00+00:00", CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind), DateTimeOffset.Parse(times.Attribute("finish")!.Value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind)); + } + + [TestMethod] + public void Merge_TimesOmitAttributesNoInputSupplies() + { + // When no input carries creation/queuing, the merged report must omit them rather than invent them + // from the start time. + XDocument a = BuildTimesReport(creation: null, queuing: null, start: "2020-01-01T10:00:00.0000000+00:00", finish: "2020-01-01T11:00:00.0000000+00:00"); + XDocument b = BuildTimesReport(creation: null, queuing: null, start: "2020-01-01T09:00:00.0000000+00:00", finish: "2020-01-01T12:00:00.0000000+00:00"); + + XElement times = Child(TrxReportEngine.Merge([a, b], Guid.NewGuid(), "run").Root!, "Times"); + + Assert.IsNull(times.Attribute("creation")); + Assert.IsNull(times.Attribute("queuing")); + Assert.AreEqual(DateTimeOffset.Parse("2020-01-01T09:00:00+00:00", CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind), DateTimeOffset.Parse(times.Attribute("start")!.Value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind)); + Assert.AreEqual(DateTimeOffset.Parse("2020-01-01T12:00:00+00:00", CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind), DateTimeOffset.Parse(times.Attribute("finish")!.Value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind)); + } + + private static XDocument BuildTimesReport(string? creation, string? queuing, string? start, string? finish) + { + var times = new XElement(Ns + "Times"); + if (creation is not null) + { + times.SetAttributeValue("creation", creation); + } + + if (queuing is not null) + { + times.SetAttributeValue("queuing", queuing); + } + + if (start is not null) + { + times.SetAttributeValue("start", start); + } + + if (finish is not null) + { + times.SetAttributeValue("finish", finish); + } + + var testRun = new XElement( + Ns + "TestRun", + new XAttribute("id", Guid.NewGuid()), + new XAttribute("name", "run"), + times, + new XElement(Ns + "Results"), + new XElement(Ns + "TestDefinitions"), + new XElement(Ns + "TestEntries"), + new XElement(Ns + "TestLists", DefaultTestLists()), + new XElement( + Ns + "ResultSummary", + new XAttribute("outcome", "Completed"), + new XElement(Ns + "Counters", new XAttribute("total", 0), new XAttribute("passed", 0)))); + + return new XDocument(testRun); + } + + [TestMethod] + public void Merge_PreservesResultFileAttachmentPaths() + { + string attachmentPath = Path.Combine(Path.GetTempPath(), "results-a", "log.txt"); + XElement resultWithAttachment = new( + Ns + "UnitTestResult", + new XAttribute("executionId", "e1"), + new XElement(Ns + "ResultFiles", new XElement(Ns + "ResultFile", new XAttribute("path", attachmentPath)))); + + XElement results = Child(TrxReportEngine.Merge([BuildReport(results: [resultWithAttachment]), BuildReport()], Guid.NewGuid(), "run").Root!, "Results"); + + XElement? resultFile = results.Descendants(Ns + "ResultFile").FirstOrDefault(); + Assert.IsNotNull(resultFile); + Assert.AreEqual(attachmentPath, resultFile.Attribute("path")!.Value); + } + + [TestMethod] + public void Merge_WithSingleReport_KeepsAllResults() + { + XDocument single = BuildReport(total: 2, passed: 2, results: [Result("e1", "t1", "TestA"), Result("e2", "t2", "TestB")]); + + XElement root = TrxReportEngine.Merge([single], Guid.NewGuid(), "run").Root!; + + Assert.HasCount(2, Child(root, "Results").Elements()); + Assert.AreEqual("2", Counters(root.Document!).Attribute("total")!.Value); + } + + [TestMethod] + public async Task MergeToFileAsync_WritesMergedFileToDisk() + { + string tempDirectory = Path.Combine(Path.GetTempPath(), $"trx-merge-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDirectory); + try + { + string first = Path.Combine(tempDirectory, "a.trx"); + string second = Path.Combine(tempDirectory, "b.trx"); + string output = Path.Combine(tempDirectory, "nested", "merged.trx"); + BuildReport(total: 2, passed: 2).Save(first); + BuildReport(total: 3, passed: 3).Save(second); + + await TrxReportEngine.MergeToFileAsync([first, second], output, Guid.NewGuid(), "run", CancellationToken.None); + + Assert.IsTrue(File.Exists(output)); + var merged = XDocument.Load(output); + Assert.AreEqual("5", Counters(merged).Attribute("total")!.Value); + } + finally + { + Directory.Delete(tempDirectory, recursive: true); + } + } + + [TestMethod] + public async Task MergeToFileAsync_IsolatesCollidingAttachmentsPerInputAndRewritesHrefs() + { + string tempDirectory = Path.Combine(Path.GetTempPath(), $"trx-merge-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDirectory); + try + { + // Two inputs, each with an attachment at the SAME relative href ("machine/log.txt") but + // different bytes. Without per-input isolation the second would resolve to the first's bytes. + string firstInputDir = Path.Combine(tempDirectory, "inA"); + string secondInputDir = Path.Combine(tempDirectory, "inB"); + string first = WriteReportWithAttachment(firstInputDir, "a.trx", deploymentRoot: "depA", attachmentContent: "AAA"); + string second = WriteReportWithAttachment(secondInputDir, "b.trx", deploymentRoot: "depB", attachmentContent: "BBB"); + string output = Path.Combine(tempDirectory, "out", "merged.trx"); + + await TrxReportEngine.MergeToFileAsync([first, second], output, Guid.NewGuid(), "run", CancellationToken.None); + + string mergedRoot = XDocument.Load(output).Descendants().First(e => e.Name.LocalName == "Deployment").Attribute("runDeploymentRoot")!.Value; + string mergedInRoot = Path.Combine(tempDirectory, "out", mergedRoot, "In"); + string firstCopied = Path.Combine(mergedInRoot, "0", "machine", "log.txt"); + string secondCopied = Path.Combine(mergedInRoot, "1", "machine", "log.txt"); + Assert.IsTrue(File.Exists(firstCopied)); + Assert.IsTrue(File.Exists(secondCopied)); + Assert.AreEqual("AAA", File.ReadAllText(firstCopied)); + Assert.AreEqual("BBB", File.ReadAllText(secondCopied)); + + List hrefs = [.. XDocument.Load(output).Descendants().Where(e => e.Name.LocalName == "A").Select(e => e.Attribute("href")!.Value)]; + Assert.Contains("0/machine/log.txt", hrefs); + Assert.Contains("1/machine/log.txt", hrefs); + } + finally + { + Directory.Delete(tempDirectory, recursive: true); + } + } + + [TestMethod] + public void Merge_WithIdenticalInputs_ProducesByteIdenticalXml() + { + var runId = Guid.NewGuid(); + + // RFC 018 requires the merge to be idempotent: identical inputs, runId and runName must reproduce + // identical output (the emitted TestSettings id is derived deterministically from runId). + string first = TrxReportEngine.Merge([BuildReport(), BuildReport()], runId, "run").ToString(); + string second = TrxReportEngine.Merge([BuildReport(), BuildReport()], runId, "run").ToString(); + + Assert.AreEqual(first, second); + } + + [TestMethod] + public void Merge_DerivesDeterministicTestSettingsIdDistinctFromRunId() + { + var runId = Guid.NewGuid(); + + XDocument merged = TrxReportEngine.Merge([BuildReport()], runId, "run"); + string? settingsId = merged.Descendants().FirstOrDefault(e => e.Name.LocalName == "TestSettings")?.Attribute("id")?.Value; + + Assert.IsNotNull(settingsId); + // Deterministic yet not the run id verbatim. + Assert.AreNotEqual(runId.ToString(), settingsId); + Assert.IsTrue(Guid.TryParse(settingsId, out _)); + } + + [TestMethod] + public async Task MergeToFileAsync_WhenOutputAliasesAnInput_ThrowsArgumentException() + { + string tempDirectory = Path.Combine(Path.GetTempPath(), $"trx-merge-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDirectory); + try + { + string input = Path.Combine(tempDirectory, "a.trx"); + BuildReport().Save(input); + + // Writing the merged output over an input would destroy a read-only source; it must be rejected. + await Assert.ThrowsExactlyAsync( + () => TrxReportEngine.MergeToFileAsync([input], input, Guid.NewGuid(), "run", CancellationToken.None)); + + // The input must be left untouched. + Assert.IsTrue(File.Exists(input)); + } + finally + { + Directory.Delete(tempDirectory, recursive: true); + } + } + + [TestMethod] + public async Task MergeToFileAsync_WhenAttachmentHrefIsRooted_PreservesTheReference() + { + string tempDirectory = Path.Combine(Path.GetTempPath(), $"trx-merge-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDirectory); + try + { + // RFC 018 keeps absolute (rooted) attachment paths resolvable, so a rooted href is preserved + // unchanged rather than relocated or dropped. + string inputDir = Path.Combine(tempDirectory, "in"); + Directory.CreateDirectory(Path.Combine(inputDir, "dep", "In", "machine")); + + string rootedHref = Path.Combine(tempDirectory, "outside", "secret.txt"); + var collectorDataEntries = new XElement( + Ns + "CollectorDataEntries", + new XElement( + Ns + "Collector", + new XAttribute("collectorDisplayName", "Code Coverage"), + new XElement( + Ns + "UriAttachments", + new XElement(Ns + "UriAttachment", new XElement(Ns + "A", new XAttribute("href", rootedHref)))))); + + XDocument report = BuildReport(resultSummaryChildren: [collectorDataEntries]); + report.Root!.Add(new XElement( + Ns + "TestSettings", + new XAttribute("name", "default"), + new XElement(Ns + "Deployment", new XAttribute("runDeploymentRoot", "dep")))); + + string input = Path.Combine(inputDir, "a.trx"); + report.Save(input); + string output = Path.Combine(tempDirectory, "out", "merged.trx"); + + await TrxReportEngine.MergeToFileAsync([input], output, Guid.NewGuid(), "run", CancellationToken.None); + + List hrefs = [.. XDocument.Load(output).Descendants().Where(e => e.Name.LocalName == "A").Select(e => e.Attribute("href")!.Value)]; + Assert.HasCount(1, hrefs); + Assert.AreEqual(rootedHref, hrefs[0]); + } + finally + { + Directory.Delete(tempDirectory, recursive: true); + } + } + + [TestMethod] + public async Task MergeToFileAsync_WhenAttachmentHrefEscapesRoot_DropsTheReference() + { + string tempDirectory = Path.Combine(Path.GetTempPath(), $"trx-merge-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDirectory); + try + { + string inputDir = Path.Combine(tempDirectory, "in"); + Directory.CreateDirectory(inputDir); + + // A hostile relative href that climbs above the deployment root. Even though it is not rooted, + // resolving it from the merged deployment directory would escape the output tree, so the merge + // must drop the reference rather than emit a traversal href. + string attachmentDirectory = Path.Combine(inputDir, "dep", "In", "machine"); + Directory.CreateDirectory(attachmentDirectory); + File.WriteAllText(Path.Combine(attachmentDirectory, "log.txt"), "AAA"); + + var collectorDataEntries = new XElement( + Ns + "CollectorDataEntries", + new XElement( + Ns + "Collector", + new XAttribute("collectorDisplayName", "Code Coverage"), + new XElement( + Ns + "UriAttachments", + new XElement(Ns + "UriAttachment", new XElement(Ns + "A", new XAttribute("href", "../../../secret")))))); + + XDocument report = BuildReport(resultSummaryChildren: [collectorDataEntries]); + report.Root!.Add(new XElement( + Ns + "TestSettings", + new XAttribute("name", "default"), + new XElement(Ns + "Deployment", new XAttribute("runDeploymentRoot", "dep")))); + + string input = Path.Combine(inputDir, "a.trx"); + report.Save(input); + string output = Path.Combine(tempDirectory, "out", "merged.trx"); + + await TrxReportEngine.MergeToFileAsync([input], output, Guid.NewGuid(), "run", CancellationToken.None); + + var mergedDoc = XDocument.Load(output); + List hrefs = [.. mergedDoc.Descendants().Where(e => e.Name.LocalName == "A").Select(e => e.Attribute("href")!.Value)]; + Assert.IsEmpty(hrefs); + // Dropping the escaping must also remove its owning so no schema-invalid + // empty element is left behind. + Assert.IsEmpty(mergedDoc.Descendants().Where(e => e.Name.LocalName == "UriAttachment")); + } + finally + { + Directory.Delete(tempDirectory, recursive: true); + } + } + + [TestMethod] + public async Task MergeToFileAsync_RelocatesPerTestResultFilesByPrefixingResultDirectory() + { + string tempDirectory = Path.Combine(Path.GetTempPath(), $"trx-merge-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDirectory); + try + { + // Per-test ResultFiles resolve under In//. The physical file + // lives at /In///, and the ResultFile + // path is '/'. Relocation must prefix the directory (not the path) so the + // merged href resolves to the copied bytes. + string inputDir = Path.Combine(tempDirectory, "in"); + const string executionId = "exec-1111"; + string physical = Path.Combine(inputDir, "dep", "In", executionId, "machine"); + Directory.CreateDirectory(physical); + File.WriteAllText(Path.Combine(physical, "log.txt"), "AAA"); + + var unitTestResult = new XElement( + Ns + "UnitTestResult", + new XAttribute("executionId", executionId), + new XAttribute("relativeResultsDirectory", executionId), + new XElement(Ns + "ResultFiles", new XElement(Ns + "ResultFile", new XAttribute("path", "machine/log.txt")))); + + XDocument report = BuildReport(results: [unitTestResult]); + report.Root!.Add(new XElement( + Ns + "TestSettings", + new XAttribute("name", "default"), + new XElement(Ns + "Deployment", new XAttribute("runDeploymentRoot", "dep")))); + + string input = Path.Combine(inputDir, "a.trx"); + report.Save(input); + string output = Path.Combine(tempDirectory, "out", "merged.trx"); + + await TrxReportEngine.MergeToFileAsync([input], output, Guid.NewGuid(), "run", CancellationToken.None); + + var mergedDoc = XDocument.Load(output); + XElement mergedResult = mergedDoc.Descendants().First(e => e.Name.LocalName == "UnitTestResult"); + string relativeDirectory = mergedResult.Attribute("relativeResultsDirectory")!.Value; + string resultFilePath = mergedResult.Descendants().First(e => e.Name.LocalName == "ResultFile").Attribute("path")!.Value; + + // The directory is prefixed with the per-input isolation folder; the path is left intact. + Assert.AreEqual($"0/{executionId}", relativeDirectory); + Assert.AreEqual("machine/log.txt", resultFilePath); + + // The consumer-resolved path (In//) must point at real bytes. + string mergedRoot = mergedDoc.Descendants().First(e => e.Name.LocalName == "Deployment").Attribute("runDeploymentRoot")!.Value; + string resolved = Path.Combine(tempDirectory, "out", mergedRoot, "In", relativeDirectory.Replace('/', Path.DirectorySeparatorChar), resultFilePath.Replace('/', Path.DirectorySeparatorChar)); + Assert.IsTrue(File.Exists(resolved)); + Assert.AreEqual("AAA", File.ReadAllText(resolved)); + } + finally + { + Directory.Delete(tempDirectory, recursive: true); + } + } + + [TestMethod] + public async Task MergeToFileAsync_WhenResultDirectoryEscapesRoot_DropsResultFile() + { + string tempDirectory = Path.Combine(Path.GetTempPath(), $"trx-merge-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDirectory); + try + { + // A hostile relativeResultsDirectory with a benign path: consumers resolve + // In//, which escapes. The reference must be dropped. + string inputDir = Path.Combine(tempDirectory, "in"); + string physical = Path.Combine(inputDir, "dep", "In", "machine"); + Directory.CreateDirectory(physical); + File.WriteAllText(Path.Combine(physical, "log.txt"), "AAA"); + + var unitTestResult = new XElement( + Ns + "UnitTestResult", + new XAttribute("executionId", "exec-1"), + new XAttribute("relativeResultsDirectory", "../../.."), + new XElement(Ns + "ResultFiles", new XElement(Ns + "ResultFile", new XAttribute("path", "machine/log.txt")))); + + XDocument report = BuildReport(results: [unitTestResult]); + report.Root!.Add(new XElement( + Ns + "TestSettings", + new XAttribute("name", "default"), + new XElement(Ns + "Deployment", new XAttribute("runDeploymentRoot", "dep")))); + + string input = Path.Combine(inputDir, "a.trx"); + report.Save(input); + string output = Path.Combine(tempDirectory, "out", "merged.trx"); + + await TrxReportEngine.MergeToFileAsync([input], output, Guid.NewGuid(), "run", CancellationToken.None); + + List resultFilePaths = [.. XDocument.Load(output).Descendants().Where(e => e.Name.LocalName == "ResultFile").Select(e => e.Attribute("path")!.Value)]; + Assert.IsEmpty(resultFilePaths); + } + finally + { + Directory.Delete(tempDirectory, recursive: true); + } + } + + [TestMethod] + public async Task MergeToFileAsync_WhenReferencedAttachmentIsNotMaterialized_DropsTheReference() + { + string tempDirectory = Path.Combine(Path.GetTempPath(), $"trx-merge-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDirectory); + try + { + // The report references an attachment that has no physical backing (e.g. it was a skipped + // source symlink, or a partial copy). The relocation must not emit a dangling href. + string inputDir = Path.Combine(tempDirectory, "in"); + Directory.CreateDirectory(Path.Combine(inputDir, "dep", "In", "machine")); + + var collectorDataEntries = new XElement( + Ns + "CollectorDataEntries", + new XElement( + Ns + "Collector", + new XAttribute("collectorDisplayName", "Code Coverage"), + new XElement( + Ns + "UriAttachments", + new XElement(Ns + "UriAttachment", new XElement(Ns + "A", new XAttribute("href", "machine/missing.txt")))))); + + XDocument report = BuildReport(resultSummaryChildren: [collectorDataEntries]); + report.Root!.Add(new XElement( + Ns + "TestSettings", + new XAttribute("name", "default"), + new XElement(Ns + "Deployment", new XAttribute("runDeploymentRoot", "dep")))); + + string input = Path.Combine(inputDir, "a.trx"); + report.Save(input); + string output = Path.Combine(tempDirectory, "out", "merged.trx"); + + await TrxReportEngine.MergeToFileAsync([input], output, Guid.NewGuid(), "run", CancellationToken.None); + + List hrefs = [.. XDocument.Load(output).Descendants().Where(e => e.Name.LocalName == "A").Select(e => e.Attribute("href")!.Value)]; + Assert.IsEmpty(hrefs); + } + finally + { + Directory.Delete(tempDirectory, recursive: true); + } + } + + [TestMethod] + public async Task MergeToFileAsync_WhenInputHasNoMaterializedSource_DropsAllReferences() + { + string tempDirectory = Path.Combine(Path.GetTempPath(), $"trx-merge-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDirectory); + try + { + // The report references an attachment but its deployment 'In' root does not exist at all, so + // the source cannot be relocated. Its references are relative to the input's own deployment + // root and would dangle against the (different) merged deployment root, so they must all be + // dropped rather than carried through unchanged. + string inputDir = Path.Combine(tempDirectory, "in"); + Directory.CreateDirectory(inputDir); + + var collectorDataEntries = new XElement( + Ns + "CollectorDataEntries", + new XElement( + Ns + "Collector", + new XAttribute("collectorDisplayName", "Code Coverage"), + new XElement( + Ns + "UriAttachments", + new XElement(Ns + "UriAttachment", new XElement(Ns + "A", new XAttribute("href", "machine/log.txt")))))); + + XDocument report = BuildReport(resultSummaryChildren: [collectorDataEntries]); + report.Root!.Add(new XElement( + Ns + "TestSettings", + new XAttribute("name", "default"), + new XElement(Ns + "Deployment", new XAttribute("runDeploymentRoot", "dep")))); + + string input = Path.Combine(inputDir, "a.trx"); + report.Save(input); + string output = Path.Combine(tempDirectory, "out", "merged.trx"); + + await TrxReportEngine.MergeToFileAsync([input], output, Guid.NewGuid(), "run", CancellationToken.None); + + List hrefs = [.. XDocument.Load(output).Descendants().Where(e => e.Name.LocalName == "A").Select(e => e.Attribute("href")!.Value)]; + Assert.IsEmpty(hrefs); + } + finally + { + Directory.Delete(tempDirectory, recursive: true); + } + } + +#if NETCOREAPP + [TestMethod] + public async Task MergeToFileAsync_WhenOutputAliasesInputViaSymlinkedParent_ThrowsAndPreservesInput() + { + string tempDirectory = Path.Combine(Path.GetTempPath(), $"trx-merge-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDirectory); + try + { + string realDir = Path.Combine(tempDirectory, "real"); + Directory.CreateDirectory(realDir); + string input = Path.Combine(realDir, "a.trx"); + BuildReport().Save(input); + + string linkDir = Path.Combine(tempDirectory, "link"); + if (!TryCreateDirectorySymlink(linkDir, realDir)) + { + // Directory symlinks require privileges on some platforms (e.g. non-elevated Windows); + // skip when unavailable rather than fail. + return; + } + + // Output goes through the symlinked parent, so it is the SAME physical file as the input even + // though the textual paths differ. Canonicalization must detect this and reject it, leaving + // the input untouched. + string aliasedOutput = Path.Combine(linkDir, "a.trx"); + await Assert.ThrowsExactlyAsync( + () => TrxReportEngine.MergeToFileAsync([input], aliasedOutput, Guid.NewGuid(), "run", CancellationToken.None)); + + Assert.IsTrue(File.Exists(input)); + } + finally + { + Directory.Delete(tempDirectory, recursive: true); + } + } + + private static bool TryCreateDirectorySymlink(string linkPath, string targetPath) + { + try + { + Directory.CreateSymbolicLink(linkPath, targetPath); + return Directory.Exists(linkPath); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + return false; + } + } +#endif + + [TestMethod] + public async Task MergeToFileAsync_WhenRunNameEscapesOutputDirectory_UsesConfinedDeploymentRoot() + { + string tempDirectory = Path.Combine(Path.GetTempPath(), $"trx-merge-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDirectory); + try + { + string inputDir = Path.Combine(tempDirectory, "in"); + string input = WriteReportWithAttachment(inputDir, "a.trx", deploymentRoot: "dep", attachmentContent: "AAA"); + string output = Path.Combine(tempDirectory, "out", "merged.trx"); + + // A hostile runName of ".." must be confined to a safe leaf used consistently for both the + // emitted deployment root and attachment relocation, so nothing escapes the output directory. + await TrxReportEngine.MergeToFileAsync([input], output, Guid.NewGuid(), "..", CancellationToken.None); + + Assert.IsTrue(File.Exists(output)); + + // The merged TRX must declare a confined deployment root (never ".."). + var mergedDoc = XDocument.Load(output); + string? deploymentRoot = mergedDoc.Descendants() + .FirstOrDefault(e => e.Name.LocalName == "Deployment")?.Attribute("runDeploymentRoot")?.Value; + Assert.IsNotNull(deploymentRoot); + + // The escape value ".." is confined to a leaf beginning "_.." and carrying no path separator, + // so it can never traverse out of the output directory (the run-id suffix keeps it unique). + Assert.StartsWith("_..", deploymentRoot); + Assert.IsFalse(deploymentRoot.Contains('/') || deploymentRoot.Contains('\\')); + Assert.HasCount(1, mergedDoc.Descendants().Where(e => e.Name.LocalName == "A")); + + // The relocated attachment must live under the OUTPUT directory (confined by the "_.." leaf), + // never escaping it — asserting confinement to the output dir, not merely the temp root. + string outputDirectory = Path.GetFullPath(Path.Combine(tempDirectory, "out")); + List relocatedCopies = [.. Directory.GetFiles(outputDirectory, "log.txt", SearchOption.AllDirectories)]; + Assert.HasCount(1, relocatedCopies); + Assert.StartsWith(outputDirectory + Path.DirectorySeparatorChar, Path.GetFullPath(relocatedCopies[0])); + Assert.AreEqual("AAA", File.ReadAllText(relocatedCopies[0])); + } + finally + { + Directory.Delete(tempDirectory, recursive: true); + } + } + + [TestMethod] + public async Task MergeToFileAsync_WhenRunNameMatchesInputDeploymentRoot_UsesUniqueRootAndRelocates() + { + string tempDirectory = Path.Combine(Path.GetTempPath(), $"trx-merge-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDirectory); + try + { + // Output written beside the input, with runName matching the input's runDeploymentRoot. Even so, + // the merged deployment root is made unique per run, so it can never coincide with (or recurse + // into) the input's own tree: the attachment is relocated into the unique root, the input's + // original stays untouched, and the rewritten href resolves under the emitted root. + string input = WriteReportWithAttachment(tempDirectory, "a.trx", deploymentRoot: "run", attachmentContent: "AAA"); + string output = Path.Combine(tempDirectory, "merged.trx"); + + await TrxReportEngine.MergeToFileAsync([input], output, Guid.NewGuid(), "run", CancellationToken.None); + + Assert.IsTrue(File.Exists(output)); + + // The input's original attachment must remain (RFC 018 inputs are read-only). + Assert.IsTrue(File.Exists(Path.Combine(tempDirectory, "run", "In", "machine", "log.txt"))); + + var mergedDoc = XDocument.Load(output); + string mergedRoot = mergedDoc.Descendants().First(e => e.Name.LocalName == "Deployment").Attribute("runDeploymentRoot")!.Value; + Assert.AreNotEqual("run", mergedRoot); + + List hrefs = [.. mergedDoc.Descendants().Where(e => e.Name.LocalName == "A").Select(e => e.Attribute("href")!.Value)]; + Assert.HasCount(1, hrefs); + string resolved = Path.Combine(tempDirectory, mergedRoot, "In", hrefs[0].Replace('/', Path.DirectorySeparatorChar)); + Assert.IsTrue(File.Exists(resolved)); + Assert.AreEqual("AAA", File.ReadAllText(resolved)); + } + finally + { + Directory.Delete(tempDirectory, recursive: true); + } + } + + [TestMethod] + public async Task MergeToFileAsync_WhenSourceNestedUnderMergedRoot_RelocatesAndKeepsHrefsValid() + { + string tempDirectory = Path.Combine(Path.GetTempPath(), $"trx-merge-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDirectory); + try + { + // Output written beside a deeply-nested input. The merged deployment root is unique, so the + // rewritten href resolves to the relocated copy under the emitted root (read from the report, + // never assumed to be the plain runName). + string inputDir = Path.Combine(tempDirectory, "run", "In", "child"); + string input = WriteReportWithAttachment(inputDir, "a.trx", deploymentRoot: "dep", attachmentContent: "AAA"); + string output = Path.Combine(tempDirectory, "merged.trx"); + + await TrxReportEngine.MergeToFileAsync([input], output, Guid.NewGuid(), "run", CancellationToken.None); + + Assert.IsTrue(File.Exists(output)); + var mergedDoc = XDocument.Load(output); + string mergedRoot = mergedDoc.Descendants().First(e => e.Name.LocalName == "Deployment").Attribute("runDeploymentRoot")!.Value; + List hrefs = [.. mergedDoc.Descendants().Where(e => e.Name.LocalName == "A").Select(e => e.Attribute("href")!.Value)]; + Assert.HasCount(1, hrefs); + // The rewritten href must resolve to a real file under the merged deployment root. + string resolved = Path.Combine(tempDirectory, mergedRoot, "In", hrefs[0].Replace('/', Path.DirectorySeparatorChar)); + Assert.IsTrue(File.Exists(resolved)); + Assert.AreEqual("AAA", File.ReadAllText(resolved)); + } + finally + { + Directory.Delete(tempDirectory, recursive: true); + } + } + + [TestMethod] + public async Task MergeToFileAsync_RepeatedIntoSameNestedLayout_IsBoundedAndNonDestructive() + { + string tempDirectory = Path.Combine(Path.GetTempPath(), $"trx-merge-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDirectory); + try + { + // Layout where the input attachment lives under a 'dep' tree and the merged output is written + // deep inside it. Repeated merges of the SAME logical run (a stable run id, hence a stable + // unique deployment root) must stay bounded and non-destructive: only referenced files are + // copied (never the whole tree), so the original stays untouched and there is exactly one + // relocated copy no matter how many times the merge runs. + string input = WriteReportWithAttachment(tempDirectory, "a.trx", deploymentRoot: "dep", attachmentContent: "AAA"); + string output = Path.Combine(tempDirectory, "dep", "In", "out", "merged.trx"); + + var runId = Guid.NewGuid(); + for (int run = 0; run < 4; run++) + { + await TrxReportEngine.MergeToFileAsync([input], output, runId, "run", CancellationToken.None); + } + + Assert.IsTrue(File.Exists(output)); + + // The original source report and attachment must survive every repeated merge untouched. + Assert.IsTrue(File.Exists(input)); + string originalAttachment = Path.Combine(tempDirectory, "dep", "In", "machine", "log.txt"); + Assert.IsTrue(File.Exists(originalAttachment)); + Assert.AreEqual("AAA", File.ReadAllText(originalAttachment)); + + // Original attachment + exactly one relocated copy — bounded, not one-per-run. + List copies = [.. Directory.GetFiles(tempDirectory, "log.txt", SearchOption.AllDirectories)]; + Assert.HasCount(2, copies); + + // The merged report's rewritten href must resolve to the relocated copy under the merged + // deployment root (read from the report; unique per run). + var mergedDoc = XDocument.Load(output); + string mergedRoot = mergedDoc.Descendants().First(e => e.Name.LocalName == "Deployment").Attribute("runDeploymentRoot")!.Value; + List hrefs = [.. mergedDoc.Descendants().Where(e => e.Name.LocalName == "A").Select(e => e.Attribute("href")!.Value)]; + Assert.HasCount(1, hrefs); + string resolved = Path.Combine(tempDirectory, "dep", "In", "out", mergedRoot, "In", hrefs[0].Replace('/', Path.DirectorySeparatorChar)); + Assert.IsTrue(File.Exists(resolved)); + Assert.AreEqual("AAA", File.ReadAllText(resolved)); + } + finally + { + Directory.Delete(tempDirectory, recursive: true); + } + } + + [TestMethod] + public async Task MergeToFileAsync_WhenInputLivesUnderMergedRoot_PreservesOriginalReportAndAttachment() + { + string tempDirectory = Path.Combine(Path.GetTempPath(), $"trx-merge-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDirectory); + try + { + // The input report and its attachment live under a 'run/In' tree while the output is written + // beside them with runName 'run'. Relocation must never delete the originals (RFC 018 requires + // they remain), and the unique merged root keeps relocation clear of the input's own tree. + string inputDir = Path.Combine(tempDirectory, "run", "In", "0"); + string input = WriteReportWithAttachment(inputDir, "a.trx", deploymentRoot: "dep", attachmentContent: "AAA"); + string output = Path.Combine(tempDirectory, "merged.trx"); + + await TrxReportEngine.MergeToFileAsync([input], output, Guid.NewGuid(), "run", CancellationToken.None); + + Assert.IsTrue(File.Exists(output)); + // Original report and original attachment must still exist. + Assert.IsTrue(File.Exists(input)); + Assert.IsTrue(File.Exists(Path.Combine(inputDir, "dep", "In", "machine", "log.txt"))); + } + finally + { + Directory.Delete(tempDirectory, recursive: true); + } + } + + [TestMethod] + public async Task MergeToFileAsync_SecondMergeIntoSameOutput_DoesNotCorruptPriorReportsAttachments() + { + string tempDirectory = Path.Combine(Path.GetTempPath(), $"trx-merge-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDirectory); + try + { + // A first merge commits a report plus its relocated attachment tree. A second, DIFFERENT merge + // (distinct run id) into the same output path must write into its own unique deployment root and + // leave the first report's referenced files byte-for-byte intact — so a failure of the second + // merge could never corrupt an already-committed report (RFC 018 data-integrity). + string firstInput = WriteReportWithAttachment(Path.Combine(tempDirectory, "in1"), "a.trx", deploymentRoot: "dep", attachmentContent: "FIRST"); + string output = Path.Combine(tempDirectory, "out", "merged.trx"); + await TrxReportEngine.MergeToFileAsync([firstInput], output, Guid.NewGuid(), "run", CancellationToken.None); + + var firstDoc = XDocument.Load(output); + string firstRoot = firstDoc.Descendants().First(e => e.Name.LocalName == "Deployment").Attribute("runDeploymentRoot")!.Value; + string firstHref = firstDoc.Descendants().First(e => e.Name.LocalName == "A").Attribute("href")!.Value; + string firstCopy = Path.Combine(tempDirectory, "out", firstRoot, "In", firstHref.Replace('/', Path.DirectorySeparatorChar)); + Assert.IsTrue(File.Exists(firstCopy)); + + string secondInput = WriteReportWithAttachment(Path.Combine(tempDirectory, "in2"), "b.trx", deploymentRoot: "dep", attachmentContent: "SECOND"); + await TrxReportEngine.MergeToFileAsync([secondInput], output, Guid.NewGuid(), "run", CancellationToken.None); + + // The second merge used a distinct unique root, so the first report's attachment is untouched. + string secondRoot = XDocument.Load(output).Descendants().First(e => e.Name.LocalName == "Deployment").Attribute("runDeploymentRoot")!.Value; + Assert.AreNotEqual(firstRoot, secondRoot); + Assert.IsTrue(File.Exists(firstCopy)); + Assert.AreEqual("FIRST", File.ReadAllText(firstCopy)); + } + finally + { + Directory.Delete(tempDirectory, recursive: true); + } + } + + private static string WriteReportWithAttachment(string inputDirectory, string fileName, string deploymentRoot, string attachmentContent) + { + Directory.CreateDirectory(inputDirectory); + + // Physical attachment under "/In/machine/log.txt", referenced by a + // machine-relative href of "machine/log.txt". + string attachmentDirectory = Path.Combine(inputDirectory, deploymentRoot, "In", "machine"); + Directory.CreateDirectory(attachmentDirectory); + File.WriteAllText(Path.Combine(attachmentDirectory, "log.txt"), attachmentContent); + + var collectorDataEntries = new XElement( + Ns + "CollectorDataEntries", + new XElement( + Ns + "Collector", + new XAttribute("collectorDisplayName", "Code Coverage"), + new XElement( + Ns + "UriAttachments", + new XElement(Ns + "UriAttachment", new XElement(Ns + "A", new XAttribute("href", "machine/log.txt")))))); + + XDocument report = BuildReport(resultSummaryChildren: [collectorDataEntries]); + report.Root!.Add(new XElement( + Ns + "TestSettings", + new XAttribute("name", "default"), + new XElement(Ns + "Deployment", new XAttribute("runDeploymentRoot", deploymentRoot)))); + + string path = Path.Combine(inputDirectory, fileName); + report.Save(path); + return path; + } + + private static XElement Child(XElement parent, string localName) + => parent.Elements().First(e => e.Name.LocalName == localName); + + private static XElement ResultSummary(XDocument document) + => Child(document.Root!, "ResultSummary"); + + private static XElement Counters(XDocument document) + => Child(ResultSummary(document), "Counters"); + + private static XElement Result(string executionId, string testId, string testName) + => new( + Ns + "UnitTestResult", + new XAttribute("executionId", executionId), + new XAttribute("testId", testId), + new XAttribute("testName", testName)); + + private static XElement TestList(string id, string name) + => new(Ns + "TestList", new XAttribute("id", id), new XAttribute("name", name)); + + private static XElement[] DefaultTestLists() + => + [ + TestList("8C84FA94-04C1-424b-9868-57A2D4851A1D", "Results Not in a List"), + TestList("19431567-8539-422a-85D7-44EE4E166BDA", "All Loaded Results"), + ]; + + private static XDocument BuildReport( + string outcome = "Completed", + int total = 1, + int passed = 1, + int failed = 0, + int notExecuted = 0, + int timeout = 0, + DateTimeOffset? start = null, + DateTimeOffset? finish = null, + IEnumerable? results = null, + IEnumerable? testDefinitions = null, + IEnumerable? testEntries = null, + IEnumerable? testLists = null, + IEnumerable? resultSummaryChildren = null) + { + DateTimeOffset startTime = start ?? new DateTimeOffset(2020, 1, 1, 10, 0, 0, TimeSpan.Zero); + DateTimeOffset finishTime = finish ?? new DateTimeOffset(2020, 1, 1, 11, 0, 0, TimeSpan.Zero); + + var resultSummary = new XElement( + Ns + "ResultSummary", + new XAttribute("outcome", outcome), + new XElement( + Ns + "Counters", + new XAttribute("total", total), + new XAttribute("executed", passed + failed), + new XAttribute("passed", passed), + new XAttribute("failed", failed), + new XAttribute("timeout", timeout), + new XAttribute("notExecuted", notExecuted))); + + if (resultSummaryChildren is not null) + { + resultSummary.Add(resultSummaryChildren); + } + + var testRun = new XElement( + Ns + "TestRun", + new XAttribute("id", Guid.NewGuid()), + new XAttribute("name", "run"), + new XElement( + Ns + "Times", + new XAttribute("creation", startTime), + new XAttribute("queuing", startTime), + new XAttribute("start", startTime), + new XAttribute("finish", finishTime)), + new XElement(Ns + "Results", results ?? []), + new XElement(Ns + "TestDefinitions", testDefinitions ?? []), + new XElement(Ns + "TestEntries", testEntries ?? []), + new XElement(Ns + "TestLists", testLists ?? DefaultTestLists()), + resultSummary); + + return new XDocument(testRun); + } +} diff --git a/test/UnitTests/Microsoft.Testing.Platform.DotnetTestProtocolContract.UnitTests/DotnetTestProtocolSerializerTests.cs b/test/UnitTests/Microsoft.Testing.Platform.DotnetTestProtocolContract.UnitTests/DotnetTestProtocolSerializerTests.cs index 933e7de0d8..d7390c0edc 100644 --- a/test/UnitTests/Microsoft.Testing.Platform.DotnetTestProtocolContract.UnitTests/DotnetTestProtocolSerializerTests.cs +++ b/test/UnitTests/Microsoft.Testing.Platform.DotnetTestProtocolContract.UnitTests/DotnetTestProtocolSerializerTests.cs @@ -145,6 +145,21 @@ static ushort GetConstantValue(string fieldName) => (ushort)typeof(FailedTestResultMessageFieldsId).GetField(fieldName)!.GetRawConstantValue()!; } + [TestMethod] + public void FileArtifactMessageFieldIds_Kind_IsStable() + { + // Kind is an externally shared wire id (the SDK decodes it with its own vendored copy of the same + // number), so renumbering it would silently break cross-process decoding without failing the + // round-trip tests (which use the same constant on both writer and reader). Pin the literal here. + // The declared value is read via reflection (a runtime read, not a compile-time constant) so that + // renumbering the constant is actually caught rather than folded away by the compiler. + Assert.AreEqual((ushort)6, GetConstantValue(nameof(FileArtifactMessageFieldsId.SessionUid))); + Assert.AreEqual((ushort)7, GetConstantValue(nameof(FileArtifactMessageFieldsId.Kind))); + + static ushort GetConstantValue(string fieldName) + => (ushort)typeof(FileArtifactMessageFieldsId).GetField(fieldName)!.GetRawConstantValue()!; + } + [TestMethod] public void DiscoveredTestMessages_RoundTripsWithTraitsAndParameters() { @@ -228,8 +243,8 @@ public void FileArtifactMessages_RoundTrips() "executionId", "instanceId", [ - new FileArtifactMessage("full/path.txt", "artifact", "desc", "testUid", "testDisplay", "sessionUid"), - new FileArtifactMessage("other.txt", "other", null, null, null, null), + new FileArtifactMessage("full/path.txt", "artifact", "desc", "testUid", "testDisplay", "sessionUid", "microsoft.testing.trx"), + new FileArtifactMessage("other.txt", "other", null, null, null, null, null), ]); FileArtifactMessages actual = RoundTrip(new FileArtifactMessagesSerializer(), message); @@ -238,8 +253,10 @@ public void FileArtifactMessages_RoundTrips() Assert.HasCount(2, actual.FileArtifacts); Assert.AreEqual("full/path.txt", actual.FileArtifacts[0].FullPath); Assert.AreEqual("sessionUid", actual.FileArtifacts[0].SessionUid); + Assert.AreEqual("microsoft.testing.trx", actual.FileArtifacts[0].Kind); Assert.AreEqual("other.txt", actual.FileArtifacts[1].FullPath); Assert.IsNull(actual.FileArtifacts[1].Description); + Assert.IsNull(actual.FileArtifacts[1].Kind); } [TestMethod] diff --git a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/IPC/ProtocolEdgeCaseTests.cs b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/IPC/ProtocolEdgeCaseTests.cs index ae5953e7fd..86e6eb54a2 100644 --- a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/IPC/ProtocolEdgeCaseTests.cs +++ b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/IPC/ProtocolEdgeCaseTests.cs @@ -141,9 +141,9 @@ public void FileArtifactMessages_WhenMultipleArtifactsWithMixedNulls_RoundTrips( "exec", "inst", [ - new FileArtifactMessage("/a/b.trx", "b.trx", "a trx", "uid", "Test", "session"), - new FileArtifactMessage("/c/d.coverage", "d.coverage", null, null, null, null), - new FileArtifactMessage("/e/f.txt", null, null, "uid2", null, "session2"), + new FileArtifactMessage("/a/b.trx", "b.trx", "a trx", "uid", "Test", "session", "microsoft.testing.trx"), + new FileArtifactMessage("/c/d.coverage", "d.coverage", null, null, null, null, null), + new FileArtifactMessage("/e/f.txt", null, null, "uid2", null, "session2", null), ]); FileArtifactMessages actual = RoundTrip(new FileArtifactMessagesSerializer(), message); @@ -152,6 +152,8 @@ public void FileArtifactMessages_WhenMultipleArtifactsWithMixedNulls_RoundTrips( Assert.AreEqual("inst", actual.InstanceId); Assert.AreEqual("/c/d.coverage", actual.FileArtifacts[1].FullPath); Assert.IsNull(actual.FileArtifacts[1].Description); + Assert.AreEqual("microsoft.testing.trx", actual.FileArtifacts[0].Kind); + Assert.IsNull(actual.FileArtifacts[1].Kind); Assert.AreEqual("session2", actual.FileArtifacts[2].SessionUid); Assert.IsNull(actual.FileArtifacts[2].DisplayName); } diff --git a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/IPC/ProtocolTests.cs b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/IPC/ProtocolTests.cs index adf79da4e4..f87eb784fa 100644 --- a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/IPC/ProtocolTests.cs +++ b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/IPC/ProtocolTests.cs @@ -383,8 +383,8 @@ public void FileArtifactMessagesSerializeDeserialize() "MyExecId", "MyInstId", [ - new FileArtifactMessage("/full/path/artifact1.txt", "artifact1", "description1", "uid-1", "Test 1", "session-1"), - new FileArtifactMessage("/full/path/artifact2.coverage", "artifact2", null, null, null, null), + new FileArtifactMessage("/full/path/artifact1.txt", "artifact1", "description1", "uid-1", "Test 1", "session-1", "microsoft.testing.trx"), + new FileArtifactMessage("/full/path/artifact2.coverage", "artifact2", null, null, null, null, null), ]); var stream = new MemoryStream(); @@ -405,6 +405,7 @@ public void FileArtifactMessagesSerializeDeserialize() Assert.AreEqual(expected.TestUid, actualArtifact.TestUid); Assert.AreEqual(expected.TestDisplayName, actualArtifact.TestDisplayName); Assert.AreEqual(expected.SessionUid, actualArtifact.SessionUid); + Assert.AreEqual(expected.Kind, actualArtifact.Kind); } } diff --git a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/ServerMode/DotnetTestDataConsumerTests.cs b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/ServerMode/DotnetTestDataConsumerTests.cs index 179f48b46b..5366016fdd 100644 --- a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/ServerMode/DotnetTestDataConsumerTests.cs +++ b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/ServerMode/DotnetTestDataConsumerTests.cs @@ -1,10 +1,11 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// 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.Reflection; using Microsoft.Testing.Platform.Extensions.Messages; using Microsoft.Testing.Platform.IPC; +using Microsoft.Testing.Platform.IPC.Models; using Microsoft.Testing.Platform.TestHost; namespace Microsoft.Testing.Platform.UnitTests; @@ -81,6 +82,53 @@ public void GetTestNodeDetails_WhenErroredWithAssertData_DoesNotPopulateExpected Assert.IsNull(details.Actual); } + [TestMethod] + public void CreateFileArtifactMessages_FromSessionFileArtifact_PreservesKind() + { + // Exercises the producer-to-wire mapping the serializer round-trip tests bypass (they start from + // an already-populated FileArtifactMessage). Without this a regression that dropped the Kind here + // would break post-processing grouping while every serializer test stayed green. + var artifact = new SessionFileArtifact(new SessionUid("session"), new FileInfo("/path/a.trx"), "a.trx", "desc", "microsoft.testing.trx"); + + FileArtifactMessages messages = DotnetTestDataConsumer.CreateFileArtifactMessages("exec-1", artifact); + + Assert.AreEqual("exec-1", messages.ExecutionId); + Assert.HasCount(1, messages.FileArtifacts); + Assert.AreEqual("microsoft.testing.trx", messages.FileArtifacts[0].Kind); + Assert.AreEqual("session", messages.FileArtifacts[0].SessionUid); + } + + [TestMethod] + public void CreateFileArtifactMessages_FromSessionFileArtifactWithoutKind_SendsNullKind() + { + var artifact = new SessionFileArtifact(new SessionUid("session"), new FileInfo("/path/a.txt"), "a.txt"); + + FileArtifactMessages messages = DotnetTestDataConsumer.CreateFileArtifactMessages("exec-1", artifact); + + Assert.IsNull(messages.FileArtifacts[0].Kind); + } + + [TestMethod] + public void CreateFileArtifactMessages_FromFileArtifact_PreservesKind() + { + var artifact = new FileArtifact(new FileInfo("/path/a.trx"), "a.trx", "desc", "microsoft.testing.trx"); + + FileArtifactMessages messages = DotnetTestDataConsumer.CreateFileArtifactMessages("exec-1", artifact); + + Assert.HasCount(1, messages.FileArtifacts); + Assert.AreEqual("microsoft.testing.trx", messages.FileArtifacts[0].Kind); + } + + [TestMethod] + public void CreateFileArtifactMessages_FromFileArtifactWithoutKind_SendsNullKind() + { + var artifact = new FileArtifact(new FileInfo("/path/a.txt"), "a.txt"); + + FileArtifactMessages messages = DotnetTestDataConsumer.CreateFileArtifactMessages("exec-1", artifact); + + Assert.IsNull(messages.FileArtifacts[0].Kind); + } + private static DotnetTestDataConsumer.TestNodeDetails InvokeGetTestNodeDetails(IProperty stateProperty) { TestNodeUpdateMessage testNodeUpdateMessage = new(new SessionUid("session"), new TestNode