diff --git a/src/Platform/Microsoft.Testing.Extensions.JUnitReport/TestResultCapture.cs b/src/Platform/Microsoft.Testing.Extensions.JUnitReport/TestResultCapture.cs index ee7d42710a..2d223ae467 100644 --- a/src/Platform/Microsoft.Testing.Extensions.JUnitReport/TestResultCapture.cs +++ b/src/Platform/Microsoft.Testing.Extensions.JUnitReport/TestResultCapture.cs @@ -44,12 +44,47 @@ public static ParentChainEntry GetParentChainEntry(TestNodeUpdateMessage update) return null; } - string outcome = ClassifyOutcome(state); + // Keep the O(1) state lookup above as a fast path for non-terminal messages. Terminal + // results collect all remaining required properties in one pass — replacing + // 4 × SingleOrDefault() + 1 × foreach/GetEnumerator() with one zero-allocation + // GetStructEnumerator() pass. Singleton-typed properties use the local GetSingleOrDefaultValue + // helper to preserve the throw-on-duplicate invariant that SingleOrDefault() provided; + // TestMetadataProperty is intentionally multi-valued and accumulates into a list. + TimingProperty? timing = null; + TestMethodIdentifierProperty? identifier = null; + StandardOutputProperty? standardOutput = null; + StandardErrorProperty? standardError = null; + List>? traits = null; - TimingProperty? timing = node.Properties.SingleOrDefault(); - TimeSpan duration = timing?.GlobalTiming.Duration ?? TimeSpan.Zero; + PropertyBag.PropertyBagEnumerator enumerator = node.Properties.GetStructEnumerator(); + while (enumerator.MoveNext()) + { + switch (enumerator.Current) + { + case TimingProperty t: timing = GetSingleOrDefaultValue(timing, t); break; + case TestMethodIdentifierProperty m: identifier = GetSingleOrDefaultValue(identifier, m); break; + case StandardOutputProperty so: standardOutput = GetSingleOrDefaultValue(standardOutput, so); break; + case StandardErrorProperty se: standardError = GetSingleOrDefaultValue(standardError, se); break; + case TestMetadataProperty meta: + // Trait keys and values are test-controlled so we truncate them to + // bound the size of the in-memory result list and generated XML. + traits ??= []; + traits.Add(new KeyValuePair( + Truncate(meta.Key, MaxTraitFieldLength)!, + Truncate(meta.Value, MaxTraitFieldLength)!)); + break; + } + } - (string? className, string? methodName) = GetClassAndMethodName(node); + static TProperty GetSingleOrDefaultValue(TProperty? existingProperty, TProperty property) + where TProperty : class, IProperty + => existingProperty is not null + ? throw new InvalidOperationException($"Found multiple properties of type '{typeof(TProperty)}'.") + : property; + + string outcome = ClassifyOutcome(state); + TimeSpan duration = timing?.GlobalTiming.Duration ?? TimeSpan.Zero; + (string? className, string? methodName) = GetClassAndMethodName(identifier); string? errorMessage = state.Explanation; string? stackTrace = null; @@ -72,24 +107,6 @@ public static ParentChainEntry GetParentChainEntry(TestNodeUpdateMessage update) exceptionType = exception.GetType().FullName; } - string? stdout = node.Properties.SingleOrDefault()?.StandardOutput; - string? stderr = node.Properties.SingleOrDefault()?.StandardError; - - // Collect traits without using LINQ to avoid an enumerator allocation per node. - // Trait keys and values are also test-controlled so we truncate them as well to - // bound the size of the in-memory result list and generated XML. - List>? traits = null; - foreach (IProperty p in node.Properties) - { - if (p is TestMetadataProperty meta) - { - traits ??= []; - traits.Add(new KeyValuePair( - Truncate(meta.Key, MaxTraitFieldLength)!, - Truncate(meta.Value, MaxTraitFieldLength)!)); - } - } - return new CapturedTestResult { // Identity fields are test-controlled and can be unbounded (e.g. very long @@ -111,8 +128,8 @@ public static ParentChainEntry GetParentChainEntry(TestNodeUpdateMessage update) ErrorMessage = Truncate(errorMessage, MaxMessageLength), ExceptionType = exceptionType, StackTrace = Truncate(stackTrace, MaxStackTraceLength), - StandardOutput = Truncate(stdout, MaxStandardStreamLength), - StandardError = Truncate(stderr, MaxStandardStreamLength), + StandardOutput = Truncate(standardOutput?.StandardOutput, MaxStandardStreamLength), + StandardError = Truncate(standardError?.StandardError, MaxStandardStreamLength), Traits = traits, }; } @@ -137,9 +154,8 @@ _ when Array.IndexOf(TestNodePropertiesCategories.WellKnownTestNodeTestRunOutcom _ => throw ApplicationStateGuard.Unreachable(), }; - private static (string? ClassName, string? MethodName) GetClassAndMethodName(TestNode node) + private static (string? ClassName, string? MethodName) GetClassAndMethodName(TestMethodIdentifierProperty? identifier) { - TestMethodIdentifierProperty? identifier = node.Properties.SingleOrDefault(); if (identifier is null) { return (null, null); diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/JUnitReportTestResultCaptureTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/JUnitReportTestResultCaptureTests.cs new file mode 100644 index 0000000000..e1b5d0a880 --- /dev/null +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/JUnitReportTestResultCaptureTests.cs @@ -0,0 +1,39 @@ +// 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; +using Microsoft.Testing.Platform.Extensions.Messages; +using Microsoft.Testing.Platform.TestHost; + +namespace Microsoft.Testing.Extensions.UnitTests; + +[TestClass] +public class JUnitReportTestResultCaptureTests +{ + [TestMethod] + public void TryCapture_DoesNotWalkTerminalProperties_ForNonTerminalStates() + { + var bag = new PropertyBag( + DiscoveredTestNodeStateProperty.CachedInstance, + new TimingProperty(new TimingInfo(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, TimeSpan.Zero)), + new TimingProperty(new TimingInfo(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, TimeSpan.Zero))); + TestNode node = new() { Uid = "a", DisplayName = "x", Properties = bag }; + + Assert.IsNull(TestResultCapture.TryCapture(new TestNodeUpdateMessage(new SessionUid("1"), node))); + } + + [TestMethod] + public void TryCapture_DuplicateSingletonProperty_Throws() + { + var bag = new PropertyBag( + PassedTestNodeStateProperty.CachedInstance, + new TimingProperty(new TimingInfo(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, TimeSpan.Zero)), + new TimingProperty(new TimingInfo(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, TimeSpan.Zero))); + TestNode node = new() { Uid = "a", DisplayName = "x", Properties = bag }; + + InvalidOperationException ex = Assert.ThrowsExactly(() => + TestResultCapture.TryCapture(new TestNodeUpdateMessage(new SessionUid("1"), node))); + + Assert.Contains(nameof(TimingProperty), ex.Message); + } +}