diff --git a/src/Platform/Microsoft.Testing.Extensions.HtmlReport/Microsoft.Testing.Extensions.HtmlReport.csproj b/src/Platform/Microsoft.Testing.Extensions.HtmlReport/Microsoft.Testing.Extensions.HtmlReport.csproj
index a768b4b09b..fba261f720 100644
--- a/src/Platform/Microsoft.Testing.Extensions.HtmlReport/Microsoft.Testing.Extensions.HtmlReport.csproj
+++ b/src/Platform/Microsoft.Testing.Extensions.HtmlReport/Microsoft.Testing.Extensions.HtmlReport.csproj
@@ -46,6 +46,7 @@ This package extends Microsoft Testing Platform to produce self-contained HTML t
+
diff --git a/src/Platform/Microsoft.Testing.Extensions.HtmlReport/TestResultCapture.cs b/src/Platform/Microsoft.Testing.Extensions.HtmlReport/TestResultCapture.cs
index e48beed890..6efe24277f 100644
--- a/src/Platform/Microsoft.Testing.Extensions.HtmlReport/TestResultCapture.cs
+++ b/src/Platform/Microsoft.Testing.Extensions.HtmlReport/TestResultCapture.cs
@@ -1,10 +1,7 @@
// 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;
using Microsoft.Testing.Platform.Extensions.Messages;
-using Microsoft.Testing.Platform.Helpers;
-using Microsoft.Testing.Platform.Messages;
namespace Microsoft.Testing.Extensions.HtmlReport;
@@ -13,11 +10,11 @@ namespace Microsoft.Testing.Extensions.HtmlReport;
// stdout/stderr/stack traces) in memory for the whole session.
internal static class TestResultCapture
{
- internal const int MaxStandardStreamLength = 32 * 1024;
- internal const int MaxStackTraceLength = 32 * 1024;
- internal const int MaxMessageLength = 16 * 1024;
- internal const int MaxIdentityFieldLength = 4 * 1024;
- internal const int MaxTraitFieldLength = 1024;
+ internal const int MaxStandardStreamLength = TestResultCaptureHelper.MaxStandardStreamLength;
+ internal const int MaxStackTraceLength = TestResultCaptureHelper.MaxStackTraceLength;
+ internal const int MaxMessageLength = TestResultCaptureHelper.MaxMessageLength;
+ internal const int MaxIdentityFieldLength = TestResultCaptureHelper.MaxIdentityFieldLength;
+ internal const int MaxTraitFieldLength = TestResultCaptureHelper.MaxTraitFieldLength;
public static CapturedTestResult? TryCapture(TestNode node)
{
@@ -32,7 +29,8 @@ internal static class TestResultCapture
TimingProperty? timing = node.Properties.SingleOrDefault();
TimeSpan duration = timing?.GlobalTiming.Duration ?? TimeSpan.Zero;
- (string? className, string? methodName) = GetClassAndMethodName(node);
+ TestMethodIdentifierProperty? identifier = node.Properties.SingleOrDefault();
+ (string? className, string? methodName) = TestResultCaptureHelper.GetClassAndMethodName(identifier);
string? errorMessage = state.Explanation;
string? stackTrace = null;
@@ -96,51 +94,11 @@ internal static class TestResultCapture
}
private static string ClassifyOutcome(TestNodeStateProperty state)
- => state switch
- {
- PassedTestNodeStateProperty => "passed",
- SkippedTestNodeStateProperty => "skipped",
- TimeoutTestNodeStateProperty => "timedOut",
- ErrorTestNodeStateProperty => "errored",
- FailedTestNodeStateProperty => "failed",
- _ when Array.IndexOf(TestNodePropertiesCategories.WellKnownTestNodeTestRunOutcomeFailedProperties, state.GetType()) >= 0
- => "failed",
- _ => throw ApplicationStateGuard.Unreachable(),
- };
-
- private static (string? ClassName, string? MethodName) GetClassAndMethodName(TestNode node)
- {
- TestMethodIdentifierProperty? identifier = node.Properties.SingleOrDefault();
- if (identifier is null)
- {
- return (null, null);
- }
-
- string className = RoslynString.IsNullOrEmpty(identifier.Namespace)
- ? identifier.TypeName
- : $"{identifier.Namespace}.{identifier.TypeName}";
-
- return (className, identifier.MethodName);
- }
+ // Cancellation is intentionally handled in report-specific wrappers. HTML has
+ // historically let cancellation fall through to the failed outcome category, so
+ // this wrapper simply delegates to the shared helper with no special-casing.
+ => TestResultCaptureHelper.ClassifyOutcome(state);
internal static string? Truncate(string? value, int maxLength)
- {
- if (value is null || value.Length <= maxLength)
- {
- return value;
- }
-
- // Don't split a surrogate pair when truncating: drop the high surrogate too.
- // The early return above guarantees cut < value.Length here, but we keep the
- // explicit guard so the invariant is locally self-documenting and survives
- // any future refactor that might call this block with cut == value.Length.
- int cut = maxLength;
- if (cut > 0 && cut < value.Length && char.IsHighSurrogate(value[cut - 1]))
- {
- cut--;
- }
-
- return value.Substring(0, cut)
- + $"\n…[truncated, original length: {value.Length.ToString(CultureInfo.InvariantCulture)}]";
- }
+ => TestResultCaptureHelper.Truncate(value, maxLength);
}
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 fb7e44559e..7c5463e305 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
@@ -50,6 +50,7 @@ This package extends Microsoft Testing Platform to produce JUnit XML test report
+
diff --git a/src/Platform/Microsoft.Testing.Extensions.JUnitReport/TestResultCapture.cs b/src/Platform/Microsoft.Testing.Extensions.JUnitReport/TestResultCapture.cs
index 2d223ae467..dbadf6041a 100644
--- a/src/Platform/Microsoft.Testing.Extensions.JUnitReport/TestResultCapture.cs
+++ b/src/Platform/Microsoft.Testing.Extensions.JUnitReport/TestResultCapture.cs
@@ -1,10 +1,7 @@
// 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;
using Microsoft.Testing.Platform.Extensions.Messages;
-using Microsoft.Testing.Platform.Helpers;
-using Microsoft.Testing.Platform.Messages;
namespace Microsoft.Testing.Extensions.JUnitReport;
@@ -13,11 +10,11 @@ namespace Microsoft.Testing.Extensions.JUnitReport;
// stdout/stderr/stack traces) in memory for the whole session.
internal static class TestResultCapture
{
- internal const int MaxStandardStreamLength = 32 * 1024;
- internal const int MaxStackTraceLength = 32 * 1024;
- internal const int MaxMessageLength = 16 * 1024;
- internal const int MaxIdentityFieldLength = 4 * 1024;
- internal const int MaxTraitFieldLength = 1024;
+ internal const int MaxStandardStreamLength = TestResultCaptureHelper.MaxStandardStreamLength;
+ internal const int MaxStackTraceLength = TestResultCaptureHelper.MaxStackTraceLength;
+ internal const int MaxMessageLength = TestResultCaptureHelper.MaxMessageLength;
+ internal const int MaxIdentityFieldLength = TestResultCaptureHelper.MaxIdentityFieldLength;
+ internal const int MaxTraitFieldLength = TestResultCaptureHelper.MaxTraitFieldLength;
// The display name of a test node is also captured for non-terminal (Discovered /
// InProgress) messages so that the engine can reconstruct the parent chain for
@@ -84,7 +81,7 @@ static TProperty GetSingleOrDefaultValue(TProperty? existingProperty,
string outcome = ClassifyOutcome(state);
TimeSpan duration = timing?.GlobalTiming.Duration ?? TimeSpan.Zero;
- (string? className, string? methodName) = GetClassAndMethodName(identifier);
+ (string? className, string? methodName) = TestResultCaptureHelper.GetClassAndMethodName(identifier);
string? errorMessage = state.Explanation;
string? stackTrace = null;
@@ -135,54 +132,21 @@ static TProperty GetSingleOrDefaultValue(TProperty? existingProperty,
}
private static string ClassifyOutcome(TestNodeStateProperty state)
- => state switch
- {
- PassedTestNodeStateProperty => "passed",
- SkippedTestNodeStateProperty => "skipped",
- TimeoutTestNodeStateProperty => "timedOut",
- ErrorTestNodeStateProperty => "errored",
- FailedTestNodeStateProperty => "failed",
-#pragma warning disable CS0618, MTP0001 // CancelledTestNodeStateProperty is obsolete
- // Cancellation is an interruption, not an assertion failure. The RFC
- // maps it to in the generated XML; classifying it as its own
- // bucket here (rather than letting it fall through to "failed") keeps
- // that mapping local to the engine's outcome switch.
- CancelledTestNodeStateProperty => "cancelled",
-#pragma warning restore CS0618, MTP0001
- _ when Array.IndexOf(TestNodePropertiesCategories.WellKnownTestNodeTestRunOutcomeFailedProperties, state.GetType()) >= 0
- => "failed",
- _ => throw ApplicationStateGuard.Unreachable(),
- };
-
- private static (string? ClassName, string? MethodName) GetClassAndMethodName(TestMethodIdentifierProperty? identifier)
{
- if (identifier is null)
+#pragma warning disable CS0618, MTP0001 // CancelledTestNodeStateProperty is obsolete
+ // Cancellation is an interruption, not an assertion failure. The RFC maps it
+ // to in the generated XML; classifying it as its own bucket here
+ // keeps that mapping local to JUnit. Keep the cancellation decision out of
+ // the shared helper so each report format preserves its existing behavior.
+ if (state is CancelledTestNodeStateProperty)
{
- return (null, null);
+ return "cancelled";
}
+#pragma warning restore CS0618, MTP0001
- string className = RoslynString.IsNullOrEmpty(identifier.Namespace)
- ? identifier.TypeName
- : $"{identifier.Namespace}.{identifier.TypeName}";
-
- return (className, identifier.MethodName);
+ return TestResultCaptureHelper.ClassifyOutcome(state);
}
internal static string? Truncate(string? value, int maxLength)
- {
- if (value is null || value.Length <= maxLength)
- {
- return value;
- }
-
- // Don't split a surrogate pair when truncating: drop the high surrogate too.
- int cut = maxLength;
- if (cut > 0 && char.IsHighSurrogate(value[cut - 1]))
- {
- cut--;
- }
-
- return value.Substring(0, cut)
- + $"\n…[truncated, original length: {value.Length.ToString(CultureInfo.InvariantCulture)}]";
- }
+ => TestResultCaptureHelper.Truncate(value, maxLength);
}
diff --git a/src/Platform/SharedExtensionHelpers/TestResultCaptureHelper.cs b/src/Platform/SharedExtensionHelpers/TestResultCaptureHelper.cs
new file mode 100644
index 0000000000..8391477326
--- /dev/null
+++ b/src/Platform/SharedExtensionHelpers/TestResultCaptureHelper.cs
@@ -0,0 +1,66 @@
+// 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;
+using Microsoft.Testing.Platform.Extensions.Messages;
+using Microsoft.Testing.Platform.Helpers;
+using Microsoft.Testing.Platform.Messages;
+
+namespace Microsoft.Testing.Extensions;
+
+internal static class TestResultCaptureHelper
+{
+ internal const int MaxStandardStreamLength = 32 * 1024;
+ internal const int MaxStackTraceLength = 32 * 1024;
+ internal const int MaxMessageLength = 16 * 1024;
+ internal const int MaxIdentityFieldLength = 4 * 1024;
+ internal const int MaxTraitFieldLength = 1024;
+
+ internal static string ClassifyOutcome(TestNodeStateProperty state)
+ => state switch
+ {
+ PassedTestNodeStateProperty => "passed",
+ SkippedTestNodeStateProperty => "skipped",
+ TimeoutTestNodeStateProperty => "timedOut",
+ ErrorTestNodeStateProperty => "errored",
+ FailedTestNodeStateProperty => "failed",
+ _ when Array.IndexOf(TestNodePropertiesCategories.WellKnownTestNodeTestRunOutcomeFailedProperties, state.GetType()) >= 0
+ => "failed",
+ _ => throw ApplicationStateGuard.Unreachable(),
+ };
+
+ internal static (string? ClassName, string? MethodName) GetClassAndMethodName(TestMethodIdentifierProperty? identifier)
+ {
+ if (identifier is null)
+ {
+ return (null, null);
+ }
+
+ string className = RoslynString.IsNullOrEmpty(identifier.Namespace)
+ ? identifier.TypeName
+ : $"{identifier.Namespace}.{identifier.TypeName}";
+
+ return (className, identifier.MethodName);
+ }
+
+ internal static string? Truncate(string? value, int maxLength)
+ {
+ if (value is null || value.Length <= maxLength)
+ {
+ return value;
+ }
+
+ // Don't split a surrogate pair when truncating: drop the high surrogate too.
+ // The early return above guarantees cut < value.Length here, but we keep the
+ // explicit guard so the invariant is locally self-documenting and survives
+ // any future refactor that might call this block with cut == value.Length.
+ int cut = maxLength;
+ if (cut > 0 && cut < value.Length && char.IsHighSurrogate(value[cut - 1]))
+ {
+ cut--;
+ }
+
+ return value.Substring(0, cut)
+ + $"\n…[truncated, original length: {value.Length.ToString(CultureInfo.InvariantCulture)}]";
+ }
+}
diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TestResultCaptureHelperTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TestResultCaptureHelperTests.cs
new file mode 100644
index 0000000000..091b8f5cfb
--- /dev/null
+++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TestResultCaptureHelperTests.cs
@@ -0,0 +1,136 @@
+// 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.Extensions.HtmlReport;
+using Microsoft.Testing.Platform.Extensions.Messages;
+
+namespace Microsoft.Testing.Extensions.UnitTests;
+
+[TestClass]
+public sealed class TestResultCaptureHelperTests
+{
+ // TestResultCaptureHelper is linked into multiple extension assemblies (HtmlReport, JUnitReport).
+ // Pick one explicitly so direct type references stay unambiguous.
+ private static readonly Type HelperType =
+ typeof(HtmlReportEngine).Assembly.GetType("Microsoft.Testing.Extensions.TestResultCaptureHelper", throwOnError: true)!;
+
+ private static readonly MethodInfo TruncateMethod =
+ HelperType.GetMethod("Truncate", BindingFlags.NonPublic | BindingFlags.Static)
+ ?? throw new InvalidOperationException("Could not resolve TestResultCaptureHelper.Truncate.");
+
+ private static readonly MethodInfo ClassifyOutcomeMethod =
+ HelperType.GetMethod("ClassifyOutcome", BindingFlags.NonPublic | BindingFlags.Static)
+ ?? throw new InvalidOperationException("Could not resolve TestResultCaptureHelper.ClassifyOutcome.");
+
+ private static readonly MethodInfo GetClassAndMethodNameMethod =
+ HelperType.GetMethod("GetClassAndMethodName", BindingFlags.NonPublic | BindingFlags.Static)
+ ?? throw new InvalidOperationException("Could not resolve TestResultCaptureHelper.GetClassAndMethodName.");
+
+ private static string? InvokeTruncate(string? value, int maxLength)
+ => (string?)TruncateMethod.Invoke(null, [value, maxLength]);
+
+ private static string InvokeClassifyOutcome(TestNodeStateProperty state)
+ => (string)ClassifyOutcomeMethod.Invoke(null, [state])!;
+
+ private static (string? ClassName, string? MethodName) InvokeGetClassAndMethodName(TestMethodIdentifierProperty? identifier)
+ => ((string?, string?))GetClassAndMethodNameMethod.Invoke(null, [identifier])!;
+
+ [TestMethod]
+ public void Truncate_NullValue_ReturnsNull()
+ => Assert.IsNull(InvokeTruncate(null, 100));
+
+ [TestMethod]
+ public void Truncate_ValueShorterThanMax_ReturnsValueUnchanged()
+ => Assert.AreEqual("abc", InvokeTruncate("abc", 100));
+
+ [TestMethod]
+ public void Truncate_ValueExactlyMax_ReturnsValueUnchanged()
+ => Assert.AreEqual("abcde", InvokeTruncate("abcde", 5));
+
+ [TestMethod]
+ public void Truncate_ValueLongerThanMax_ReturnsTruncatedWithSuffix()
+ {
+ string? result = InvokeTruncate("abcdefghij", 4);
+ Assert.IsNotNull(result);
+ Assert.IsTrue(result.StartsWith("abcd", StringComparison.Ordinal));
+ Assert.Contains("[truncated, original length: 10]", result);
+ }
+
+ [TestMethod]
+ public void Truncate_DoesNotSplitSurrogatePair_DropsHighSurrogate()
+ {
+ // U+1F600 (😀) is encoded as the surrogate pair D83D DE00.
+ // Truncating at length 3 would otherwise leave a dangling high surrogate at index 2.
+ // With the guard, the high surrogate at index 2 is dropped and the truncation suffix
+ // starts immediately after "ab" — so the result must equal "ab" + suffix exactly.
+ string value = "ab\uD83D\uDE00cd";
+ string? result = InvokeTruncate(value, 3);
+ Assert.AreEqual($"ab\n…[truncated, original length: {value.Length}]", result);
+ }
+
+ [TestMethod]
+ public void ClassifyOutcome_Passed_ReturnsPassed()
+ => Assert.AreEqual("passed", InvokeClassifyOutcome(new PassedTestNodeStateProperty()));
+
+ [TestMethod]
+ public void ClassifyOutcome_Skipped_ReturnsSkipped()
+ => Assert.AreEqual("skipped", InvokeClassifyOutcome(new SkippedTestNodeStateProperty()));
+
+ [TestMethod]
+ public void ClassifyOutcome_Timeout_ReturnsTimedOut()
+ => Assert.AreEqual("timedOut", InvokeClassifyOutcome(new TimeoutTestNodeStateProperty()));
+
+ [TestMethod]
+ public void ClassifyOutcome_Error_ReturnsErrored()
+ => Assert.AreEqual("errored", InvokeClassifyOutcome(new ErrorTestNodeStateProperty()));
+
+ [TestMethod]
+ public void ClassifyOutcome_Failed_ReturnsFailed()
+ => Assert.AreEqual("failed", InvokeClassifyOutcome(new FailedTestNodeStateProperty()));
+
+ [TestMethod]
+#pragma warning disable CS0618, MTP0001 // Cancelled* is obsolete but still in the well-known failed set.
+ public void ClassifyOutcome_Cancelled_FallsThroughToFailed()
+ => Assert.AreEqual("failed", InvokeClassifyOutcome(new CancelledTestNodeStateProperty()));
+#pragma warning restore CS0618, MTP0001
+
+ [TestMethod]
+ public void GetClassAndMethodName_NullIdentifier_ReturnsNullPair()
+ => Assert.AreEqual<(string?, string?)>((null, null), InvokeGetClassAndMethodName(null));
+
+ [TestMethod]
+ public void GetClassAndMethodName_WithNamespace_PrependsNamespace()
+ {
+ var identifier = new TestMethodIdentifierProperty(
+ assemblyFullName: "MyAsm",
+ @namespace: "My.Ns",
+ typeName: "MyType",
+ methodName: "MyMethod",
+ methodArity: 0,
+ parameterTypeFullNames: [],
+ returnTypeFullName: "System.Void");
+
+ (string? className, string? methodName) = InvokeGetClassAndMethodName(identifier);
+ Assert.AreEqual("My.Ns.MyType", className);
+ Assert.AreEqual("MyMethod", methodName);
+ }
+
+ [TestMethod]
+ public void GetClassAndMethodName_WithoutNamespace_ReturnsBareTypeName()
+ {
+ var identifier = new TestMethodIdentifierProperty(
+ assemblyFullName: "MyAsm",
+ @namespace: string.Empty,
+ typeName: "MyType",
+ methodName: "MyMethod",
+ methodArity: 0,
+ parameterTypeFullNames: [],
+ returnTypeFullName: "System.Void");
+
+ (string? className, string? methodName) = InvokeGetClassAndMethodName(identifier);
+ Assert.AreEqual("MyType", className);
+ Assert.AreEqual("MyMethod", methodName);
+ }
+}