// 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; using Microsoft.Testing.Platform.Extensions.Messages; using Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter; using Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.Extensions; using Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.Helpers; using Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.ObjectModel; using Microsoft.VisualStudio.TestPlatform.MSTestAdapter.PlatformServices.Helpers; using Microsoft.VisualStudio.TestPlatform.MSTestAdapter.PlatformServices.Resources; using Microsoft.VisualStudio.TestPlatform.ObjectModel; using FrameworkTestResult = Microsoft.VisualStudio.TestTools.UnitTesting.TestResult; namespace Microsoft.VisualStudio.TestTools.UnitTesting; /// /// Builds Microsoft.Testing.Platform s directly from MSTest's neutral execution model /// ( and the framework ), without going through the /// VSTest object model (TestCase/TestResult) or the VSTest bridge. /// /// /// This mirrors, field-for-field, the mapping that the VSTest bridge performed in /// ObjectModelConverters.ToTestNode (combined with UnitTestElementExtensions.ToTestCase, /// TestResultExtensions.ToTestResult and the bridge's AddAdditionalProperties), so /// switching MSTest to a native Microsoft.Testing.Platform integration produces identical s. /// MSTest does not use the vstestProvider named-feature capability, so the VSTest provider properties the /// bridge conditionally emits are intentionally not reproduced here. /// [SuppressMessage("ApiDesign", "RS0030:Do not use banned APIs", Justification = "We can use MTP from this folder")] internal static class MSTestTestNodeConverter { /// /// Caches the immutable base representation used to create every node for a test element. /// /// /// A conversion still creates a distinct and for every message. /// Only immutable properties are shared. Properties that expose mutable arrays are materialized from private /// snapshots for each node. The weak key naturally bounds the cache to the lifetime of the element. /// /// Although is mutable while discovery constructs it, the element is fully /// specialized before its first node is published. Data-row and source transformations create distinct element /// clones, so later lifecycle messages for one element observe the same base metadata captured here. /// /// #pragma warning disable IDE0028 // ConditionalWeakTable is not collection-expression-constructible on .NET Framework (CS9174). private static readonly ConditionalWeakTable BaseTestNodeDataCache = new(); #pragma warning restore IDE0028 /// /// Builds a discovered-state for a discovered test. /// public static TestNode ToDiscoveredTestNode(UnitTestElement element, bool isTrxEnabled) { TestNode testNode = CreateBaseTestNode(element, isTrxEnabled, displayNameOverride: null, out _); testNode.Properties.Add(DiscoveredTestNodeStateProperty.CachedInstance); return testNode; } /// /// Builds an in-progress-state reported when a test starts executing. /// public static TestNode ToInProgressTestNode(UnitTestElement element, bool isTrxEnabled) { TestNode testNode = CreateBaseTestNode(element, isTrxEnabled, displayNameOverride: null, out _); testNode.Properties.Add(InProgressTestNodeStateProperty.CachedInstance); return testNode; } /// /// Builds a state-neutral node update that signals execution completed without producing a result. /// public static TestNode ToEmptyResultTestNode(UnitTestElement element, bool isTrxEnabled) { TestNode testNode = CreateBaseTestNode(element, isTrxEnabled, displayNameOverride: null, out _); testNode.Properties.Add(TestNodeExecutionCompletedProperty.CachedInstance); return testNode; } /// /// Builds a completed carrying the outcome, timing, output and (optionally) TRX /// properties for a single executed test result. /// public static TestNode ToResultTestNode(UnitTestElement element, FrameworkTestResult result, DateTimeOffset startTime, DateTimeOffset endTime, bool isTrxEnabled, MSTestSettings settings) { TestNode testNode = CreateBaseTestNode(element, isTrxEnabled, result.DisplayName, out BaseTestNodeData baseData); // Mirror TestResultExtensions.ToTestResult: the reported error message prefers the exception message and // falls back to the ignore reason; the stack trace comes straight from the framework result. string? errorMessage = result.ExceptionMessage ?? result.IgnoreReason; string? errorStackTrace = result.ExceptionStackTrace; var outcome = UnitTestOutcomeHelper.ToTestOutcome(result.Outcome, settings); AddOutcome(testNode, outcome, errorMessage, errorStackTrace); // Surface the structured assertion values so consumers can render an expected-vs-actual diff. They // cannot be read back from the reported exception: AddOutcome reports a synthetic // MSTestTestNodeException built from the message and stack trace strings, not the original // AssertFailedException. if (result.ExceptionExpectedText is not null || result.ExceptionActualText is not null) { testNode.Properties.Add(new AssertionFailureProperty(result.ExceptionExpectedText, result.ExceptionActualText)); } if (isTrxEnabled) { AddTrxResultProperties(testNode, baseData, errorMessage, errorStackTrace); } AddMessagesAndOutput(testNode, result, isTrxEnabled); testNode.Properties.Add(new TimingProperty(new(startTime, endTime, result.Duration), [])); // Surface an in-process retry (MSTest's [Retry]) so the platform can tell the attempts of one test apart // instead of seeing repeated results for the same uid. Only added when a retry actually happened, so a // regular test node is byte-identical to before. if (result.RetryAttemptNumber > 1 || result.IsSupersededRetryAttempt) { testNode.Properties.Add(new RetryAttemptProperty(result.RetryAttemptNumber, result.IsSupersededRetryAttempt)); } AddAttachments(testNode, result); return testNode; } private static TestNode CreateBaseTestNode(UnitTestElement element, bool isTrxEnabled, string? displayNameOverride, out BaseTestNodeData baseData) { baseData = BaseTestNodeDataCache.GetValue(element, BaseTestNodeData.Create); TestNode testNode = new() { Uid = baseData.Uid, DisplayName = displayNameOverride ?? baseData.DisplayName, }; baseData.AddProperties(testNode.Properties, isTrxEnabled); return testNode; } private sealed class BaseTestNodeData { private readonly TestMetadataProperty[] _categoryMetadata; private readonly TestMetadataProperty[] _traitMetadata; private readonly string[]? _trxCategories; private readonly string[]? _trxWorkItemIds; private readonly TestFileLocationProperty? _fileLocation; private readonly ParsedManagedName? _parsedManagedName; private readonly string _fullClassName; private readonly string _testMethodName; private TrxFullyQualifiedTypeNameProperty? _trxFullyQualifiedTypeNameProperty; private TrxTestDefinitionName? _trxTestDefinitionName; private BaseTestNodeData( TestNodeUid uid, string displayName, TestMetadataProperty[] categoryMetadata, TestMetadataProperty[] traitMetadata, string[]? trxCategories, string[]? trxWorkItemIds, TestFileLocationProperty? fileLocation, ParsedManagedName? parsedManagedName, string fullClassName, string testMethodName) { Uid = uid; DisplayName = displayName; _categoryMetadata = categoryMetadata; _traitMetadata = traitMetadata; _trxCategories = trxCategories; _trxWorkItemIds = trxWorkItemIds; _fileLocation = fileLocation; _parsedManagedName = parsedManagedName; _fullClassName = fullClassName; _testMethodName = testMethodName; } public TestNodeUid Uid { get; } public string DisplayName { get; } public static BaseTestNodeData Create(UnitTestElement element) { TestMethod testMethod = element.TestMethod; string[]? categories = element.TestCategory is { Length: > 0 } testCategories ? [.. testCategories] : null; TestMetadataProperty[] categoryMetadata; if (categories is null) { categoryMetadata = []; } else { categoryMetadata = new TestMetadataProperty[categories.Length]; for (int i = 0; i < categoryMetadata.Length; i++) { categoryMetadata[i] = new TestMetadataProperty(categories[i], string.Empty); } } TestTrait[]? traits = element.Traits; TestMetadataProperty[] traitMetadata; if (traits is null) { traitMetadata = []; } else { traitMetadata = new TestMetadataProperty[traits.Length]; for (int i = 0; i < traitMetadata.Length; i++) { traitMetadata[i] = new TestMetadataProperty(traits[i].Name, traits[i].Value); } } TestFileLocationProperty? fileLocation = null; if (element.DeclaringFilePath is not null) { var position = new LinePosition(element.DeclaringLineNumber ?? -1, -1); fileLocation = new TestFileLocationProperty(element.DeclaringFilePath, new(position, position)); } ParsedManagedName? parsedManagedName = GetParsedManagedName(testMethod); return new BaseTestNodeData( new TestNodeUid(element.GetTestId().ToString()), testMethod.DisplayName, categoryMetadata, traitMetadata, categories, element.WorkItemIds is { Length: > 0 } workItemIds ? [.. workItemIds] : null, fileLocation, parsedManagedName, testMethod.FullClassName, testMethod.Name); } public void AddProperties(PropertyBag properties, bool isTrxEnabled) { if (isTrxEnabled && _trxCategories is not null) { properties.Add(new TrxCategoriesProperty([.. _trxCategories])); } if (isTrxEnabled && _trxWorkItemIds is not null) { properties.Add(new TrxWorkItemsProperty([.. _trxWorkItemIds])); } for (int i = 0; i < _categoryMetadata.Length; i++) { properties.Add(_categoryMetadata[i]); } for (int i = 0; i < _traitMetadata.Length; i++) { properties.Add(_traitMetadata[i]); } if (_fileLocation is not null) { properties.Add(_fileLocation); } if (_parsedManagedName is not null) { properties.Add(_parsedManagedName.ToProperty()); } } public TrxTestDefinitionName GetTrxTestDefinitionName() { if (_trxTestDefinitionName is { } cached) { return cached; } var created = new TrxTestDefinitionName(DisplayName); return Interlocked.CompareExchange(ref _trxTestDefinitionName, created, null) ?? created; } public TrxFullyQualifiedTypeNameProperty GetTrxFullyQualifiedTypeNameProperty() { if (_trxFullyQualifiedTypeNameProperty is { } cached) { return cached; } string fullyQualifiedTypeName = _parsedManagedName is not null ? _parsedManagedName.FullyQualifiedTypeName : !StringEx.IsNullOrEmpty(_fullClassName) ? _fullClassName : throw new InvalidOperationException($"The test method '{_testMethodName}' does not have a fully qualified class name."); var created = new TrxFullyQualifiedTypeNameProperty(fullyQualifiedTypeName); return Interlocked.CompareExchange(ref _trxFullyQualifiedTypeNameProperty, created, null) ?? created; } } // ManagedMethodName carries the parameter types, so prefer it to match the VSTest bridge's test identity. private static ParsedManagedName? GetParsedManagedName(TestMethod testMethod) => !testMethod.HasManagedMethodAndTypeProperties || StringEx.IsNullOrEmpty(testMethod.ManagedTypeName) ? null : ParsedManagedName.Parse(testMethod); /// /// The parsed pieces of a 's managed type and method names, retained by /// . /// private sealed class ParsedManagedName { private readonly string _namespace; private readonly string _typeName; private readonly string _methodName; private readonly int _arity; private readonly string[] _parameterTypeFullNames; // Non-null exactly for parameterless methods, where the property is fully immutable and can therefore be // handed to every node. Built eagerly so the field can stay readonly: ToProperty() is called immediately // after every parse, so nothing is built that would not have been built anyway, and a lazy `??=` would // make the shared instance depend on call ordering. See ToProperty(). private readonly TestMethodIdentifierProperty? _parameterlessProperty; private ParsedManagedName(string @namespace, string typeName, string methodName, int arity, string[] parameterTypeFullNames) { _namespace = @namespace; _typeName = typeName; _methodName = methodName; _arity = arity; _parameterTypeFullNames = parameterTypeFullNames; // AssemblyFullName and ReturnTypeFullName are not carried by the neutral model today; kept empty to // match the current (bridge) behavior. Populating them is a follow-up enabled by this native path. if (parameterTypeFullNames.Length == 0) { _parameterlessProperty = new TestMethodIdentifierProperty( assemblyFullName: string.Empty, @namespace, typeName, methodName, arity, parameterTypeFullNames, returnTypeFullName: string.Empty); } } public static ParsedManagedName Parse(TestMethod testMethod) { // AddTestMethodIdentifier is the only caller and has already validated both managed names. string managedType = testMethod.ManagedTypeName!; string managedMethod = testMethod.ManagedMethodName!; ManagedNameParser.ParseManagedMethodName(managedMethod, out string methodName, out int arity, out string[]? parameterTypes); int lastIndexOfDot = managedType.LastIndexOf('.'); string @namespace = lastIndexOfDot == -1 ? string.Empty : managedType[..lastIndexOfDot]; string typeName = lastIndexOfDot == -1 ? managedType : managedType[(lastIndexOfDot + 1)..]; return new ParsedManagedName(@namespace, typeName, methodName, arity, parameterTypes ?? []); } public TestMethodIdentifierProperty ToProperty() { // A parameterless property is fully immutable - every field is a string or int, and the empty array // cannot be mutated - so the same instance is handed to every node. This is the common case, and the // ParsedManagedName is retained by the element's base descriptor, so the several nodes one executed // test produces (the in-progress node, then one result node per data row and retry attempt) share it. if (_parameterlessProperty is not null) { return _parameterlessProperty; } // A parameterized method must keep getting its own parameter array. TestMethodIdentifierProperty // exposes it publicly, so aliasing one array across nodes would let a consumer that writes to it // corrupt every other node built from the same test method. return new TestMethodIdentifierProperty( assemblyFullName: string.Empty, _namespace, _typeName, _methodName, _arity, [.. _parameterTypeFullNames], returnTypeFullName: string.Empty); } public string FullyQualifiedTypeName => StringEx.IsNullOrEmpty(_namespace) ? _typeName : $"{_namespace}.{_typeName}"; } private static void AddOutcome(TestNode testNode, TestOutcome outcome, string? errorMessage, string? errorStackTrace) { switch (outcome) { case TestOutcome.Passed: testNode.Properties.Add(PassedTestNodeStateProperty.CachedInstance); break; case TestOutcome.NotFound: testNode.Properties.Add(new ErrorTestNodeStateProperty(new MSTestTestNodeException(errorMessage ?? "Not found", errorStackTrace))); break; case TestOutcome.Failed: testNode.Properties.Add(new FailedTestNodeStateProperty(new MSTestTestNodeException(errorMessage, errorStackTrace))); break; case TestOutcome.None: case TestOutcome.Skipped: testNode.Properties.Add(errorMessage is null ? SkippedTestNodeStateProperty.CachedInstance : new SkippedTestNodeStateProperty(errorMessage)); break; default: throw new NotSupportedException($"Unsupported test outcome value '{outcome}'"); } } private static void AddTrxResultProperties(TestNode testNode, BaseTestNodeData baseData, string? errorMessage, string? errorStackTrace) { if (!StringEx.IsNullOrEmpty(errorMessage) || !StringEx.IsNullOrEmpty(errorStackTrace)) { testNode.Properties.Add(new TrxExceptionProperty(errorMessage, errorStackTrace)); } testNode.Properties.Add(baseData.GetTrxTestDefinitionName()); testNode.Properties.Add(baseData.GetTrxFullyQualifiedTypeNameProperty()); } private static void AddMessagesAndOutput(TestNode testNode, FrameworkTestResult result, bool isTrxEnabled) { // Reproduce, in order, the standard-out / standard-error messages that TestResultExtensions.ToTestResult // pushes onto the VSTest result and that ObjectModelConverters.ToTestNode then re-groups: LogOutput and // (banner-prefixed) DebugTrace / TestContextMessages are standard-out; LogError is standard-error. List? standardOutputMessages = null; List? standardErrorMessages = null; List? trxMessages = isTrxEnabled ? [] : null; if (!StringEx.IsNullOrEmpty(result.LogOutput)) { (standardOutputMessages ??= []).Add(result.LogOutput!); trxMessages?.Add(new StandardOutputTrxMessage(result.LogOutput)); } if (!StringEx.IsNullOrEmpty(result.LogError)) { (standardErrorMessages ??= []).Add(result.LogError!); trxMessages?.Add(new StandardErrorTrxMessage(result.LogError)); } if (!StringEx.IsNullOrEmpty(result.DebugTrace)) { string debugTraceMessagesInStdOut = $""" {Resource.DebugTraceBanner} {result.DebugTrace} """; (standardOutputMessages ??= []).Add(debugTraceMessagesInStdOut); trxMessages?.Add(new StandardOutputTrxMessage(debugTraceMessagesInStdOut)); } if (!StringEx.IsNullOrEmpty(result.TestContextMessages)) { string testContextMessagesInStdOut = $""" {Resource.TestContextMessageBanner} {result.TestContextMessages} """; (standardOutputMessages ??= []).Add(testContextMessagesInStdOut); trxMessages?.Add(new StandardOutputTrxMessage(testContextMessagesInStdOut)); } if (isTrxEnabled) { testNode.Properties.Add(new TrxMessagesProperty(trxMessages is { Count: > 0 } ? [.. trxMessages] : [])); } if (standardErrorMessages is { Count: > 0 }) { testNode.Properties.Add(new StandardErrorProperty(string.Join(Environment.NewLine, standardErrorMessages))); } if (standardOutputMessages is { Count: > 0 }) { testNode.Properties.Add(new StandardOutputProperty(string.Join(Environment.NewLine, standardOutputMessages))); } } private static void AddAttachments(TestNode testNode, FrameworkTestResult result) { if (result.ResultFiles is not { Count: > 0 }) { return; } foreach (string resultFile in result.ResultFiles) { string pathToResultFile = PlatformServiceProvider.Instance.FileOperations.GetFullFilePath(resultFile); testNode.Properties.Add(new FileArtifactProperty(new FileInfo(pathToResultFile), Resource.AttachmentSetDisplayName, resultFile)); } } }