From 05768517265b0e985e4df50b9e5504e505b359cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Tue, 11 Feb 2025 14:54:00 +0100 Subject: [PATCH 1/3] Add SourceGen + Engine --- .editorconfig | 2 + Directory.Packages.props | 2 + TestFx.sln | 28 + src/Adapter/MSTest.Engine/.editorconfig | 2 + .../Assertions/AssertFailedException.cs | 88 ++ src/Adapter/MSTest.Engine/BannedSymbols.txt | 6 + .../Configurations/ConfigurationExtensions.cs | 17 + .../Configurations/ConfigurationWrapper.cs | 20 + .../Configurations/IConfiguration.cs | 9 + .../TestFrameworkConfiguration.cs | 9 + .../Engine/BFSTestNodeVisitor.cs | 122 ++ .../Engine/ITestArgumentsEntry.cs | 13 + .../Engine/ITestArgumentsManager.cs | 11 + .../Engine/ITestExecutionContext.cs | 25 + .../Engine/ITestFixtureManager.cs | 22 + src/Adapter/MSTest.Engine/Engine/ITestInfo.cs | 15 + .../Engine/ITestSessionContext.cs | 15 + .../Engine/TestArgumentsContext.cs | 11 + .../Engine/TestArgumentsEntry.cs | 19 + .../Engine/TestArgumentsManager.cs | 158 +++ .../MSTest.Engine/Engine/TestContext.cs | 9 + .../Engine/TestExecutionContext.cs | 128 ++ .../Engine/TestFixtureManager.cs | 229 +++ .../Engine/TestFrameworkEngine.cs | 209 +++ src/Adapter/MSTest.Engine/Engine/TestInfo.cs | 17 + .../Engine/TestSessionContext.cs | 31 + .../Engine/ThreadPoolTestNodeRunner.cs | 258 ++++ .../MSTest.Engine/Helpers/AsyncLazy.cs | 19 + .../Helpers/DynamicDataNameProvider.cs | 41 + .../MSTest.Engine/Helpers/ErrorReason.cs | 19 + .../Helpers/ExceptionFlattener.cs | 31 + .../MSTest.Engine/Helpers/IErrorReason.cs | 9 + src/Adapter/MSTest.Engine/Helpers/IReason.cs | 9 + .../MSTest.Engine/Helpers/ISuccessReason.cs | 8 + .../MSTest.Engine/Helpers/IWarningReason.cs | 8 + src/Adapter/MSTest.Engine/Helpers/Result.cs | 58 + .../MSTest.Engine/Helpers/ResultExtensions.cs | 16 + .../MSTest.Engine/Helpers/SuccessReason.cs | 9 + .../TestApplicationBuilderExtensions.cs | 38 + .../Helpers/TestFrameworkConstants.cs | 10 + .../Helpers/TestNodeExpansionHelper.cs | 13 + .../Helpers/TestNodeExtensions.cs | 41 + .../MSTest.Engine/Helpers/WarningReason.cs | 9 + .../MSTest.Engine/MSTest.Engine.csproj | 111 ++ src/Adapter/MSTest.Engine/PACKAGE.md | 11 + .../PublicAPI/PublicAPI.Shipped.txt | 1 + .../PublicAPI/PublicAPI.Unshipped.txt | 98 ++ .../PublicAPI/net/PublicAPI.Shipped.txt | 1 + .../PublicAPI/net/PublicAPI.Unshipped.txt | 1 + .../netstandard/PublicAPI.Shipped.txt | 2 + .../netstandard/PublicAPI.Unshipped.txt | 1 + .../RepositoryVersion.cs.template | 10 + .../TestFramework/TestFramework.cs | 160 +++ .../TestFrameworkCapabilities.cs | 52 + .../TestingFrameworkExtension.cs | 19 + .../TestNodes/FactoryTestNodesBuilder.cs | 22 + .../TestNodes/FrameworkTestNodeProperties.cs | 13 + .../TestNodes/IActionTestNode.cs | 9 + .../TestNodes/IActionableTestNode.cs | 8 + .../TestNodes/IAsyncActionTestNode.cs | 9 + .../TestNodes/IAsyncParameterizedTestNode.cs | 11 + .../TestNodes/IContextTestNode.cs | 9 + .../TestNodes/IExpandableTestNode.cs | 9 + .../IParameterizedAsyncActionTestNode.cs | 9 + .../TestNodes/IParameterizedTestNode.cs | 9 + .../TestNodes/ITaskParameterizedTestNode.cs | 9 + .../TestNodes/ITestNodeBuilder.cs | 11 + ...ternalUnsafeActionParameterizedTestNode.cs | 41 + ...alUnsafeActionTaskParameterizedTestNode.cs | 41 + .../TestNodes/InternalUnsafeActionTestNode.cs | 15 + ...lUnsafeAsyncActionParameterizedTestNode.cs | 37 + ...afeAsyncActionTaskParameterizedTestNode.cs | 37 + .../InternalUnsafeAsyncActionTestNode.cs | 15 + .../MSTest.Engine/TestNodes/TestNode.cs | 23 + .../MSTest.Engine/TestNodes/TestNodeUid.cs | 19 + src/Adapter/MSTest.Engine/TimeSheet.cs | 64 + .../MSTest.Engine/build/MSTest.Engine.props | 3 + .../MSTest.Engine/build/MSTest.Engine.targets | 3 + .../buildMultiTargeting/MSTest.Engine.props | 13 + .../buildMultiTargeting/MSTest.Engine.targets | 3 + .../MSTest.SourceGeneration/.editorconfig | 2 + .../MSTest.SourceGeneration/BannedSymbols.txt | 2 + .../Generators/TestNodesGenerator.cs | 245 ++++ .../GlobalSuppressions.cs | 9 + .../Helpers/AttributeDataExtensions.cs | 55 + .../Helpers/Constants.cs | 9 + .../Helpers/DiagnosticCategory.cs | 9 + .../Helpers/DiagnosticExtensions.cs | 32 + .../Helpers/DisposableAction.cs | 13 + .../Helpers/EnumerableExtensions.cs | 13 + .../Helpers/ExceptionUtils.cs | 10 + .../Helpers/FileHeaderUtils.cs | 16 + .../Helpers/IMethodSymbolExtensions.cs | 64 + .../Helpers/ISymbolExtensions.cs | 66 + .../Helpers/ITypeSymbolExtensions.cs | 114 ++ .../IncrementalValuesProviderExtensions.cs | 15 + .../Helpers/IndentedStringBuilder.cs | 84 ++ .../Helpers/SymbolVisibility.cs | 39 + .../Helpers/SystemPolyfills.cs | 198 +++ .../Helpers/TestMethods.cs | 74 + .../Helpers/TestNodeHelpers.cs | 57 + .../Helpers/UnicodeCharacterUtilities.cs | 124 ++ .../Helpers/WellKnownTypes.cs | 73 + .../MSTest.SourceGeneration.csproj | 54 + .../DataRowTestMethodArgumentsInfo.cs | 206 +++ .../DynamicDataTestMethodArgumentsInfo.cs | 230 ++++ .../ObjectModels/IParameterInfo.cs | 9 + .../ObjectModels/ITestMethodArgumentsInfo.cs | 15 + .../ObjectModels/TestMethodInfo.cs | 338 +++++ .../ObjectModels/TestMethodParametersInfo.cs | 53 + .../ObjectModels/TestNamespaceInfo.cs | 48 + .../ObjectModels/TestTypeInfo.cs | 171 +++ .../MSTest.SourceGeneration/PACKAGE.md | 9 + .../MSTest.Acceptance.IntegrationTests.csproj | 2 +- .../Adapter_ExecuteRequestAsyncTests.cs | 143 ++ .../BFSTestNodeVisitorTests.cs | 279 ++++ .../MSTest.Engine.UnitTests/DataRowTests.cs | 17 + .../DynamicDataNameProviderTests.cs | 34 + .../DynamicDataTests.cs | 108 ++ .../MSTest.Engine.UnitTests.csproj | 36 + ...Test.Engine.UnitTests.launcher.config.json | 3 + ...ngine.UnitTests.testingplatformconfig.json | 8 + .../MSTest.Engine.UnitTests/Program.cs | 29 + .../Properties/launchSettings.json | 11 + .../MSTest.Engine.UnitTests/TestBase.cs | 11 + .../DataRowAttributeGenerationTests.cs | 843 ++++++++++++ .../Generators/DynamicDataAttributeTests.cs | 119 ++ .../IgnoreAttributeGenerationTests.cs | 71 + .../Generators/StaticMethodGenerationTests.cs | 55 + .../Generators/TestNodesGeneratorTests.cs | 1223 +++++++++++++++++ .../Helpers/ConstantsTests.cs | 13 + .../GeneratorCompilationResultHelpers.cs | 39 + .../Helpers/MinimalTestRunner.cs | 136 ++ .../Helpers/SourceCodeAssertionExtensions.cs | 28 + .../Helpers/SourceCodeAssertions.cs | 83 ++ .../Helpers/SyntaxExtensions.cs | 52 + .../MSTest.SourceGeneration.UnitTests.csproj | 32 + ...eGeneration.UnitTests.launcher.config.json | 3 + ...ation.UnitTests.testingplatformconfig.json | 16 + .../Microsoft.Testing.Framework.Source | 0 .../InlineTestMethodArgumentsInfoTests.cs | 30 + .../Program.cs | 29 + .../Properties/launchSettings.json | 8 + .../TestBase.cs | 11 + .../TestUtilities/CSharpCodeFixVerifier.cs | 29 + .../GeneratorCompilationResult.cs | 23 + .../TestUtilities/GeneratorTester.cs | 173 +++ .../TestUtilities/TestingFrameworkVerifier.cs | 137 ++ 148 files changed, 9011 insertions(+), 1 deletion(-) create mode 100644 src/Adapter/MSTest.Engine/.editorconfig create mode 100644 src/Adapter/MSTest.Engine/Assertions/AssertFailedException.cs create mode 100644 src/Adapter/MSTest.Engine/BannedSymbols.txt create mode 100644 src/Adapter/MSTest.Engine/Configurations/ConfigurationExtensions.cs create mode 100644 src/Adapter/MSTest.Engine/Configurations/ConfigurationWrapper.cs create mode 100644 src/Adapter/MSTest.Engine/Configurations/IConfiguration.cs create mode 100644 src/Adapter/MSTest.Engine/Configurations/TestFrameworkConfiguration.cs create mode 100644 src/Adapter/MSTest.Engine/Engine/BFSTestNodeVisitor.cs create mode 100644 src/Adapter/MSTest.Engine/Engine/ITestArgumentsEntry.cs create mode 100644 src/Adapter/MSTest.Engine/Engine/ITestArgumentsManager.cs create mode 100644 src/Adapter/MSTest.Engine/Engine/ITestExecutionContext.cs create mode 100644 src/Adapter/MSTest.Engine/Engine/ITestFixtureManager.cs create mode 100644 src/Adapter/MSTest.Engine/Engine/ITestInfo.cs create mode 100644 src/Adapter/MSTest.Engine/Engine/ITestSessionContext.cs create mode 100644 src/Adapter/MSTest.Engine/Engine/TestArgumentsContext.cs create mode 100644 src/Adapter/MSTest.Engine/Engine/TestArgumentsEntry.cs create mode 100644 src/Adapter/MSTest.Engine/Engine/TestArgumentsManager.cs create mode 100644 src/Adapter/MSTest.Engine/Engine/TestContext.cs create mode 100644 src/Adapter/MSTest.Engine/Engine/TestExecutionContext.cs create mode 100644 src/Adapter/MSTest.Engine/Engine/TestFixtureManager.cs create mode 100644 src/Adapter/MSTest.Engine/Engine/TestFrameworkEngine.cs create mode 100644 src/Adapter/MSTest.Engine/Engine/TestInfo.cs create mode 100644 src/Adapter/MSTest.Engine/Engine/TestSessionContext.cs create mode 100644 src/Adapter/MSTest.Engine/Engine/ThreadPoolTestNodeRunner.cs create mode 100644 src/Adapter/MSTest.Engine/Helpers/AsyncLazy.cs create mode 100644 src/Adapter/MSTest.Engine/Helpers/DynamicDataNameProvider.cs create mode 100644 src/Adapter/MSTest.Engine/Helpers/ErrorReason.cs create mode 100644 src/Adapter/MSTest.Engine/Helpers/ExceptionFlattener.cs create mode 100644 src/Adapter/MSTest.Engine/Helpers/IErrorReason.cs create mode 100644 src/Adapter/MSTest.Engine/Helpers/IReason.cs create mode 100644 src/Adapter/MSTest.Engine/Helpers/ISuccessReason.cs create mode 100644 src/Adapter/MSTest.Engine/Helpers/IWarningReason.cs create mode 100644 src/Adapter/MSTest.Engine/Helpers/Result.cs create mode 100644 src/Adapter/MSTest.Engine/Helpers/ResultExtensions.cs create mode 100644 src/Adapter/MSTest.Engine/Helpers/SuccessReason.cs create mode 100644 src/Adapter/MSTest.Engine/Helpers/TestApplicationBuilderExtensions.cs create mode 100644 src/Adapter/MSTest.Engine/Helpers/TestFrameworkConstants.cs create mode 100644 src/Adapter/MSTest.Engine/Helpers/TestNodeExpansionHelper.cs create mode 100644 src/Adapter/MSTest.Engine/Helpers/TestNodeExtensions.cs create mode 100644 src/Adapter/MSTest.Engine/Helpers/WarningReason.cs create mode 100644 src/Adapter/MSTest.Engine/MSTest.Engine.csproj create mode 100644 src/Adapter/MSTest.Engine/PACKAGE.md create mode 100644 src/Adapter/MSTest.Engine/PublicAPI/PublicAPI.Shipped.txt create mode 100644 src/Adapter/MSTest.Engine/PublicAPI/PublicAPI.Unshipped.txt create mode 100644 src/Adapter/MSTest.Engine/PublicAPI/net/PublicAPI.Shipped.txt create mode 100644 src/Adapter/MSTest.Engine/PublicAPI/net/PublicAPI.Unshipped.txt create mode 100644 src/Adapter/MSTest.Engine/PublicAPI/netstandard/PublicAPI.Shipped.txt create mode 100644 src/Adapter/MSTest.Engine/PublicAPI/netstandard/PublicAPI.Unshipped.txt create mode 100644 src/Adapter/MSTest.Engine/RepositoryVersion.cs.template create mode 100644 src/Adapter/MSTest.Engine/TestFramework/TestFramework.cs create mode 100644 src/Adapter/MSTest.Engine/TestFramework/TestFrameworkCapabilities.cs create mode 100644 src/Adapter/MSTest.Engine/TestFramework/TestingFrameworkExtension.cs create mode 100644 src/Adapter/MSTest.Engine/TestNodes/FactoryTestNodesBuilder.cs create mode 100644 src/Adapter/MSTest.Engine/TestNodes/FrameworkTestNodeProperties.cs create mode 100644 src/Adapter/MSTest.Engine/TestNodes/IActionTestNode.cs create mode 100644 src/Adapter/MSTest.Engine/TestNodes/IActionableTestNode.cs create mode 100644 src/Adapter/MSTest.Engine/TestNodes/IAsyncActionTestNode.cs create mode 100644 src/Adapter/MSTest.Engine/TestNodes/IAsyncParameterizedTestNode.cs create mode 100644 src/Adapter/MSTest.Engine/TestNodes/IContextTestNode.cs create mode 100644 src/Adapter/MSTest.Engine/TestNodes/IExpandableTestNode.cs create mode 100644 src/Adapter/MSTest.Engine/TestNodes/IParameterizedAsyncActionTestNode.cs create mode 100644 src/Adapter/MSTest.Engine/TestNodes/IParameterizedTestNode.cs create mode 100644 src/Adapter/MSTest.Engine/TestNodes/ITaskParameterizedTestNode.cs create mode 100644 src/Adapter/MSTest.Engine/TestNodes/ITestNodeBuilder.cs create mode 100644 src/Adapter/MSTest.Engine/TestNodes/InternalUnsafeActionParameterizedTestNode.cs create mode 100644 src/Adapter/MSTest.Engine/TestNodes/InternalUnsafeActionTaskParameterizedTestNode.cs create mode 100644 src/Adapter/MSTest.Engine/TestNodes/InternalUnsafeActionTestNode.cs create mode 100644 src/Adapter/MSTest.Engine/TestNodes/InternalUnsafeAsyncActionParameterizedTestNode.cs create mode 100644 src/Adapter/MSTest.Engine/TestNodes/InternalUnsafeAsyncActionTaskParameterizedTestNode.cs create mode 100644 src/Adapter/MSTest.Engine/TestNodes/InternalUnsafeAsyncActionTestNode.cs create mode 100644 src/Adapter/MSTest.Engine/TestNodes/TestNode.cs create mode 100644 src/Adapter/MSTest.Engine/TestNodes/TestNodeUid.cs create mode 100644 src/Adapter/MSTest.Engine/TimeSheet.cs create mode 100644 src/Adapter/MSTest.Engine/build/MSTest.Engine.props create mode 100644 src/Adapter/MSTest.Engine/build/MSTest.Engine.targets create mode 100644 src/Adapter/MSTest.Engine/buildMultiTargeting/MSTest.Engine.props create mode 100644 src/Adapter/MSTest.Engine/buildMultiTargeting/MSTest.Engine.targets create mode 100644 src/Analyzers/MSTest.SourceGeneration/.editorconfig create mode 100644 src/Analyzers/MSTest.SourceGeneration/BannedSymbols.txt create mode 100644 src/Analyzers/MSTest.SourceGeneration/Generators/TestNodesGenerator.cs create mode 100644 src/Analyzers/MSTest.SourceGeneration/GlobalSuppressions.cs create mode 100644 src/Analyzers/MSTest.SourceGeneration/Helpers/AttributeDataExtensions.cs create mode 100644 src/Analyzers/MSTest.SourceGeneration/Helpers/Constants.cs create mode 100644 src/Analyzers/MSTest.SourceGeneration/Helpers/DiagnosticCategory.cs create mode 100644 src/Analyzers/MSTest.SourceGeneration/Helpers/DiagnosticExtensions.cs create mode 100644 src/Analyzers/MSTest.SourceGeneration/Helpers/DisposableAction.cs create mode 100644 src/Analyzers/MSTest.SourceGeneration/Helpers/EnumerableExtensions.cs create mode 100644 src/Analyzers/MSTest.SourceGeneration/Helpers/ExceptionUtils.cs create mode 100644 src/Analyzers/MSTest.SourceGeneration/Helpers/FileHeaderUtils.cs create mode 100644 src/Analyzers/MSTest.SourceGeneration/Helpers/IMethodSymbolExtensions.cs create mode 100644 src/Analyzers/MSTest.SourceGeneration/Helpers/ISymbolExtensions.cs create mode 100644 src/Analyzers/MSTest.SourceGeneration/Helpers/ITypeSymbolExtensions.cs create mode 100644 src/Analyzers/MSTest.SourceGeneration/Helpers/IncrementalValuesProviderExtensions.cs create mode 100644 src/Analyzers/MSTest.SourceGeneration/Helpers/IndentedStringBuilder.cs create mode 100644 src/Analyzers/MSTest.SourceGeneration/Helpers/SymbolVisibility.cs create mode 100644 src/Analyzers/MSTest.SourceGeneration/Helpers/SystemPolyfills.cs create mode 100644 src/Analyzers/MSTest.SourceGeneration/Helpers/TestMethods.cs create mode 100644 src/Analyzers/MSTest.SourceGeneration/Helpers/TestNodeHelpers.cs create mode 100644 src/Analyzers/MSTest.SourceGeneration/Helpers/UnicodeCharacterUtilities.cs create mode 100644 src/Analyzers/MSTest.SourceGeneration/Helpers/WellKnownTypes.cs create mode 100644 src/Analyzers/MSTest.SourceGeneration/MSTest.SourceGeneration.csproj create mode 100644 src/Analyzers/MSTest.SourceGeneration/ObjectModels/DataRowTestMethodArgumentsInfo.cs create mode 100644 src/Analyzers/MSTest.SourceGeneration/ObjectModels/DynamicDataTestMethodArgumentsInfo.cs create mode 100644 src/Analyzers/MSTest.SourceGeneration/ObjectModels/IParameterInfo.cs create mode 100644 src/Analyzers/MSTest.SourceGeneration/ObjectModels/ITestMethodArgumentsInfo.cs create mode 100644 src/Analyzers/MSTest.SourceGeneration/ObjectModels/TestMethodInfo.cs create mode 100644 src/Analyzers/MSTest.SourceGeneration/ObjectModels/TestMethodParametersInfo.cs create mode 100644 src/Analyzers/MSTest.SourceGeneration/ObjectModels/TestNamespaceInfo.cs create mode 100644 src/Analyzers/MSTest.SourceGeneration/ObjectModels/TestTypeInfo.cs create mode 100644 src/Analyzers/MSTest.SourceGeneration/PACKAGE.md create mode 100644 test/UnitTests/MSTest.Engine.UnitTests/Adapter_ExecuteRequestAsyncTests.cs create mode 100644 test/UnitTests/MSTest.Engine.UnitTests/BFSTestNodeVisitorTests.cs create mode 100644 test/UnitTests/MSTest.Engine.UnitTests/DataRowTests.cs create mode 100644 test/UnitTests/MSTest.Engine.UnitTests/DynamicDataNameProviderTests.cs create mode 100644 test/UnitTests/MSTest.Engine.UnitTests/DynamicDataTests.cs create mode 100644 test/UnitTests/MSTest.Engine.UnitTests/MSTest.Engine.UnitTests.csproj create mode 100644 test/UnitTests/MSTest.Engine.UnitTests/MSTest.Engine.UnitTests.launcher.config.json create mode 100644 test/UnitTests/MSTest.Engine.UnitTests/MSTest.Engine.UnitTests.testingplatformconfig.json create mode 100644 test/UnitTests/MSTest.Engine.UnitTests/Program.cs create mode 100644 test/UnitTests/MSTest.Engine.UnitTests/Properties/launchSettings.json create mode 100644 test/UnitTests/MSTest.Engine.UnitTests/TestBase.cs create mode 100644 test/UnitTests/MSTest.SourceGeneration.UnitTests/Generators/DataRowAttributeGenerationTests.cs create mode 100644 test/UnitTests/MSTest.SourceGeneration.UnitTests/Generators/DynamicDataAttributeTests.cs create mode 100644 test/UnitTests/MSTest.SourceGeneration.UnitTests/Generators/IgnoreAttributeGenerationTests.cs create mode 100644 test/UnitTests/MSTest.SourceGeneration.UnitTests/Generators/StaticMethodGenerationTests.cs create mode 100644 test/UnitTests/MSTest.SourceGeneration.UnitTests/Generators/TestNodesGeneratorTests.cs create mode 100644 test/UnitTests/MSTest.SourceGeneration.UnitTests/Helpers/ConstantsTests.cs create mode 100644 test/UnitTests/MSTest.SourceGeneration.UnitTests/Helpers/GeneratorCompilationResultHelpers.cs create mode 100644 test/UnitTests/MSTest.SourceGeneration.UnitTests/Helpers/MinimalTestRunner.cs create mode 100644 test/UnitTests/MSTest.SourceGeneration.UnitTests/Helpers/SourceCodeAssertionExtensions.cs create mode 100644 test/UnitTests/MSTest.SourceGeneration.UnitTests/Helpers/SourceCodeAssertions.cs create mode 100644 test/UnitTests/MSTest.SourceGeneration.UnitTests/Helpers/SyntaxExtensions.cs create mode 100644 test/UnitTests/MSTest.SourceGeneration.UnitTests/MSTest.SourceGeneration.UnitTests.csproj create mode 100644 test/UnitTests/MSTest.SourceGeneration.UnitTests/MSTest.SourceGeneration.UnitTests.launcher.config.json create mode 100644 test/UnitTests/MSTest.SourceGeneration.UnitTests/MSTest.SourceGeneration.UnitTests.testingplatformconfig.json create mode 100644 test/UnitTests/MSTest.SourceGeneration.UnitTests/MSTest.SourceGeneration/Microsoft.Testing.Framework.SourceGeneration.TestNodesGenerator/Microsoft.Testing.Framework.Source create mode 100644 test/UnitTests/MSTest.SourceGeneration.UnitTests/ObjectModels/InlineTestMethodArgumentsInfoTests.cs create mode 100644 test/UnitTests/MSTest.SourceGeneration.UnitTests/Program.cs create mode 100644 test/UnitTests/MSTest.SourceGeneration.UnitTests/Properties/launchSettings.json create mode 100644 test/UnitTests/MSTest.SourceGeneration.UnitTests/TestBase.cs create mode 100644 test/UnitTests/MSTest.SourceGeneration.UnitTests/TestUtilities/CSharpCodeFixVerifier.cs create mode 100644 test/UnitTests/MSTest.SourceGeneration.UnitTests/TestUtilities/GeneratorCompilationResult.cs create mode 100644 test/UnitTests/MSTest.SourceGeneration.UnitTests/TestUtilities/GeneratorTester.cs create mode 100644 test/UnitTests/MSTest.SourceGeneration.UnitTests/TestUtilities/TestingFrameworkVerifier.cs diff --git a/.editorconfig b/.editorconfig index 9507407fa5..00516046df 100644 --- a/.editorconfig +++ b/.editorconfig @@ -259,8 +259,10 @@ dotnet_diagnostic.VSTHRD105.severity = none # VSTHRD105: Avoid metho dotnet_diagnostic.MSTESTEXP.severity = none # MSTESTEXP: Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. dotnet_diagnostic.TPEXP.severity = none # TPEXP: Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. +dotnet_diagnostic.SA1108.severity = none # SA1108: Block statements must not contain embedded comments dotnet_diagnostic.SA1600.severity = none # SA1600: Elements should be documented dotnet_diagnostic.SA1601.severity = none # SA1601: Partial elements should be documented +dotnet_diagnostic.SA1602.severity = none # SA1602: Enumeration items should be documented #### Naming styles #### diff --git a/Directory.Packages.props b/Directory.Packages.props index 3caf7bba7a..7887cedd92 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -44,6 +44,7 @@ + @@ -75,6 +76,7 @@ + diff --git a/TestFx.sln b/TestFx.sln index 7a1ee921ef..d8d48028b3 100644 --- a/TestFx.sln +++ b/TestFx.sln @@ -214,6 +214,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.Testing.Extension EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MSTest.GlobalConfigsGenerator", "src\Analyzers\MSTest.GlobalConfigsGenerator\MSTest.GlobalConfigsGenerator.csproj", "{A85AA656-6DB6-4A0B-AA80-CBB4058B3DDB}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MSTest.Engine", "src\Adapter\MSTest.Engine\MSTest.Engine.csproj", "{82881535-7E40-80D9-F086-A3847775F2E7}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MSTest.SourceGeneration", "src\Analyzers\MSTest.SourceGeneration\MSTest.SourceGeneration.csproj", "{7BA0E74E-798E-4399-2EDE-A23BD5DA78CA}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MSTest.Engine.UnitTests", "test\UnitTests\MSTest.Engine.UnitTests\MSTest.Engine.UnitTests.csproj", "{2C0DFAC0-5D58-D172-ECE4-CBB78AD03435}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MSTest.SourceGeneration.UnitTests", "test\UnitTests\MSTest.SourceGeneration.UnitTests\MSTest.SourceGeneration.UnitTests.csproj", "{E6C0466E-BE8D-C04F-149A-FD98438F1413}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -500,6 +508,22 @@ Global {A85AA656-6DB6-4A0B-AA80-CBB4058B3DDB}.Debug|Any CPU.Build.0 = Debug|Any CPU {A85AA656-6DB6-4A0B-AA80-CBB4058B3DDB}.Release|Any CPU.ActiveCfg = Release|Any CPU {A85AA656-6DB6-4A0B-AA80-CBB4058B3DDB}.Release|Any CPU.Build.0 = Release|Any CPU + {82881535-7E40-80D9-F086-A3847775F2E7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {82881535-7E40-80D9-F086-A3847775F2E7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {82881535-7E40-80D9-F086-A3847775F2E7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {82881535-7E40-80D9-F086-A3847775F2E7}.Release|Any CPU.Build.0 = Release|Any CPU + {7BA0E74E-798E-4399-2EDE-A23BD5DA78CA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7BA0E74E-798E-4399-2EDE-A23BD5DA78CA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7BA0E74E-798E-4399-2EDE-A23BD5DA78CA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7BA0E74E-798E-4399-2EDE-A23BD5DA78CA}.Release|Any CPU.Build.0 = Release|Any CPU + {2C0DFAC0-5D58-D172-ECE4-CBB78AD03435}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2C0DFAC0-5D58-D172-ECE4-CBB78AD03435}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2C0DFAC0-5D58-D172-ECE4-CBB78AD03435}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2C0DFAC0-5D58-D172-ECE4-CBB78AD03435}.Release|Any CPU.Build.0 = Release|Any CPU + {E6C0466E-BE8D-C04F-149A-FD98438F1413}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E6C0466E-BE8D-C04F-149A-FD98438F1413}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E6C0466E-BE8D-C04F-149A-FD98438F1413}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E6C0466E-BE8D-C04F-149A-FD98438F1413}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -587,6 +611,10 @@ Global {53EBA540-F6CF-0715-1F62-241A53F537EC} = {6AEE1440-FDF0-4729-8196-B24D0E333550} {FB4ED3AA-A12E-4192-861F-4B025876AA0F} = {6AEE1440-FDF0-4729-8196-B24D0E333550} {A85AA656-6DB6-4A0B-AA80-CBB4058B3DDB} = {E7F15C9C-3928-47AD-8462-64FD29FFCA54} + {82881535-7E40-80D9-F086-A3847775F2E7} = {24088844-2107-4DB2-8F3F-CBCA94FC4B28} + {7BA0E74E-798E-4399-2EDE-A23BD5DA78CA} = {E7F15C9C-3928-47AD-8462-64FD29FFCA54} + {2C0DFAC0-5D58-D172-ECE4-CBB78AD03435} = {BB874DF1-44FE-415A-B634-A6B829107890} + {E6C0466E-BE8D-C04F-149A-FD98438F1413} = {BB874DF1-44FE-415A-B634-A6B829107890} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {31E0F4D5-975A-41CC-933E-545B2201FAF9} diff --git a/src/Adapter/MSTest.Engine/.editorconfig b/src/Adapter/MSTest.Engine/.editorconfig new file mode 100644 index 0000000000..7d7bac49f4 --- /dev/null +++ b/src/Adapter/MSTest.Engine/.editorconfig @@ -0,0 +1,2 @@ +[*.{cs,vb}] +file_header_template = Copyright (c) Microsoft Corporation. All rights reserved.\nLicensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. diff --git a/src/Adapter/MSTest.Engine/Assertions/AssertFailedException.cs b/src/Adapter/MSTest.Engine/Assertions/AssertFailedException.cs new file mode 100644 index 0000000000..13efec5f35 --- /dev/null +++ b/src/Adapter/MSTest.Engine/Assertions/AssertFailedException.cs @@ -0,0 +1,88 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +using System.Runtime.Serialization; + +namespace Microsoft.Testing.Framework; + +/// +/// AssertFailedException class. Used to indicate failure for a test case. +/// +[Serializable] +public sealed class AssertFailedException : Exception +{ + /// + /// Creates AssertFailedException with a given message and metadata. + /// + /// Message to be reported to the user. + /// AssertFailedException. + internal static AssertFailedException Create(string message) => CreateInternal(message, expected: null, actual: null); + + /// + /// Creates AssertFailedException with a given message and metadata. + /// + /// Message to be reported to the user, it may, or may not include the values that were compared. If values are not included provide them to 'expected' and 'actual'. + /// The expected value, when that value is complex, and diffing it to actual makes it easier for user to see the difference. + /// The actual value, when that value is complex, and diffing it to expected makes it easier for user to see the difference. + /// AssertFailedException. + internal static AssertFailedException Create(string message, string expected, string actual) => CreateInternal(message, expected, actual); + + private static AssertFailedException CreateInternal(string message, string? expected, string? actual) + { +#pragma warning disable SYSLIB0051 // Type or member is obsolete + var ex = new AssertFailedException(message); +#pragma warning restore SYSLIB0051 // Type or member is obsolete + + if (expected != null) + { + ex.Data["assert.expected"] = expected; + } + + if (actual != null) + { + ex.Data["assert.actual"] = actual; + } + + return ex; + } + + /// + /// Initializes a new instance of the class. + /// +#if NET8_0_OR_GREATER + [Obsolete("Use Create instead", DiagnosticId = "SYSLIB0051")] +#endif + public AssertFailedException() + : base() + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The exception message. +#if NET8_0_OR_GREATER + [Obsolete("Use Create instead", DiagnosticId = "SYSLIB0051")] +#endif + public AssertFailedException(string message) + : base(message) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The exception message. + /// The inner exception. +#if NET8_0_OR_GREATER + [Obsolete("Use Create instead", DiagnosticId = "SYSLIB0051")] +#endif + public AssertFailedException(string message, Exception ex) + : base(message, ex) + { + } + + private AssertFailedException(SerializationInfo serializationInfo, StreamingContext streamingContext) + { + } +} diff --git a/src/Adapter/MSTest.Engine/BannedSymbols.txt b/src/Adapter/MSTest.Engine/BannedSymbols.txt new file mode 100644 index 0000000000..8c5d85ec6d --- /dev/null +++ b/src/Adapter/MSTest.Engine/BannedSymbols.txt @@ -0,0 +1,6 @@ +M:System.Threading.Tasks.Task.Run(System.Action,System.Threading.CancellationToken); Use 'ITask' instead +M:System.Threading.Tasks.Task.Run(System.Func{System.Threading.Tasks.Task},System.Threading.CancellationToken); Use 'ITask' instead +M:System.String.IsNullOrEmpty(System.String); Use 'RoslynString.IsNullOrEmpty' instead +M:System.String.IsNullOrWhiteSpace(System.String); Use 'RoslynString.IsNullOrWhiteSpace' instead +M:System.Diagnostics.Debug.Assert(System.Boolean); Use 'RoslynDebug.Assert' instead +M:System.Diagnostics.Debug.Assert(System.Boolean,System.String); Use 'RoslynDebug.Assert' instead diff --git a/src/Adapter/MSTest.Engine/Configurations/ConfigurationExtensions.cs b/src/Adapter/MSTest.Engine/Configurations/ConfigurationExtensions.cs new file mode 100644 index 0000000000..c8435248cb --- /dev/null +++ b/src/Adapter/MSTest.Engine/Configurations/ConfigurationExtensions.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +using Microsoft.Testing.Platform.Configurations; + +namespace Microsoft.Testing.Framework.Configurations; + +public static class ConfigurationExtensions +{ + public static string GetTestResultDirectory(this IConfiguration configuration) + => GetConfigurationWrapper(configuration).WrappedConfiguration.GetTestResultDirectory(); + + private static ConfigurationWrapper GetConfigurationWrapper(IConfiguration configuration) + => configuration is not ConfigurationWrapper configurationWrapper + ? throw new ArgumentException("Current configuration is not of expected type", nameof(configuration)) + : configurationWrapper; +} diff --git a/src/Adapter/MSTest.Engine/Configurations/ConfigurationWrapper.cs b/src/Adapter/MSTest.Engine/Configurations/ConfigurationWrapper.cs new file mode 100644 index 0000000000..3255bb7175 --- /dev/null +++ b/src/Adapter/MSTest.Engine/Configurations/ConfigurationWrapper.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +using PlatformIConfiguration = Microsoft.Testing.Platform.Configurations.IConfiguration; + +namespace Microsoft.Testing.Framework.Configurations; + +/// +/// Wraps a platform IConfiguration instance. This is preferable as it allows us to avoid being dependent +/// on Platform if we need some specific API change. +/// +internal sealed class ConfigurationWrapper : IConfiguration +{ + public ConfigurationWrapper(PlatformIConfiguration configuration) + => WrappedConfiguration = configuration; + + internal PlatformIConfiguration WrappedConfiguration { get; } + + public string? this[string key] => WrappedConfiguration[key]; +} diff --git a/src/Adapter/MSTest.Engine/Configurations/IConfiguration.cs b/src/Adapter/MSTest.Engine/Configurations/IConfiguration.cs new file mode 100644 index 0000000000..9eef90eb15 --- /dev/null +++ b/src/Adapter/MSTest.Engine/Configurations/IConfiguration.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +namespace Microsoft.Testing.Framework.Configurations; + +public interface IConfiguration +{ + string? this[string key] { get; } +} diff --git a/src/Adapter/MSTest.Engine/Configurations/TestFrameworkConfiguration.cs b/src/Adapter/MSTest.Engine/Configurations/TestFrameworkConfiguration.cs new file mode 100644 index 0000000000..ac307a5238 --- /dev/null +++ b/src/Adapter/MSTest.Engine/Configurations/TestFrameworkConfiguration.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +namespace Microsoft.Testing.Framework.Configurations; + +public sealed class TestFrameworkConfiguration(int maxParallelTests = int.MaxValue) +{ + public int MaxParallelTests { get; } = maxParallelTests; +} diff --git a/src/Adapter/MSTest.Engine/Engine/BFSTestNodeVisitor.cs b/src/Adapter/MSTest.Engine/Engine/BFSTestNodeVisitor.cs new file mode 100644 index 0000000000..c8010e1d05 --- /dev/null +++ b/src/Adapter/MSTest.Engine/Engine/BFSTestNodeVisitor.cs @@ -0,0 +1,122 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +using System.Web; + +using Microsoft.Testing.Framework.Helpers; +using Microsoft.Testing.Platform.Extensions.Messages; +using Microsoft.Testing.Platform.Requests; + +namespace Microsoft.Testing.Framework; + +#pragma warning disable TPEXP // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. +internal sealed class BFSTestNodeVisitor +{ + private readonly IEnumerable _rootTestNodes; + private readonly ITestExecutionFilter _testExecutionFilter; + private readonly TestArgumentsManager _testArgumentsManager; + + public BFSTestNodeVisitor(IEnumerable rootTestNodes, ITestExecutionFilter testExecutionFilter, + TestArgumentsManager testArgumentsManager) + { + if (testExecutionFilter is not TreeNodeFilter and not TestNodeUidListFilter and not NopFilter) + { + throw new ArgumentOutOfRangeException(nameof(testExecutionFilter)); + } + + _rootTestNodes = rootTestNodes; + _testExecutionFilter = testExecutionFilter; + _testArgumentsManager = testArgumentsManager; + } + + /// + /// Process a test node found during tree traversal. + /// + /// The test node found. + /// The UID of the node's parent. + /// The tree full path to get to the node. + /// A collection of additional test nodes to visit resulting from the processing of the node. + internal delegate ICollection TestNodeProcessor(TestNode node, TestNodeUid? parentNodeUid, string nodeFullPath); + + internal KeyValuePair>[] DuplicatedNodes { get; private set; } = Array.Empty>>(); + + public async Task VisitAsync(Func onIncludedTestNodeAsync) + { + // This is case sensitive, and culture insensitive, to keep UIDs unique, and comparable between different system. + Dictionary> testNodesByUid = new(); + Queue<(TestNode CurrentNode, TestNodeUid? ParentNodeUid, StringBuilder NodeFullPath)> queue = new(); + foreach (TestNode node in _rootTestNodes) + { + queue.Enqueue((node, null, new())); + } + + while (queue.Count > 0) + { + (TestNode currentNode, TestNodeUid? parentNodeUid, StringBuilder nodeFullPath) = queue.Dequeue(); + + if (!testNodesByUid.TryGetValue(currentNode.StableUid, out List? testNodes)) + { + testNodes = new(); + testNodesByUid.Add(currentNode.StableUid, testNodes); + } + + testNodes.Add(currentNode); + + StringBuilder nodeFullPathForChildren = new StringBuilder().Append(nodeFullPath); + + if (nodeFullPathForChildren.Length == 0 + || nodeFullPathForChildren[^1] != TreeNodeFilter.PathSeparator) + { + nodeFullPathForChildren.Append(TreeNodeFilter.PathSeparator); + } + + // We want to encode the path fragment to avoid conflicts with the separator. We are using URL encoding because it is + // a well-known proven standard encoding that is reversible. + nodeFullPathForChildren.Append(EncodeString(currentNode.OverriddenEdgeName ?? currentNode.DisplayName)); + string currentNodeFullPath = nodeFullPathForChildren.ToString(); + + // When we are filtering as tree filter and the current node does not match the filter, we skip the node and its children. + if (_testExecutionFilter is TreeNodeFilter treeNodeFilter) + { + if (!treeNodeFilter.MatchesFilter(currentNodeFullPath, CreatePropertyBagForFilter(currentNode.Properties))) + { + continue; + } + } + + // If the node is expandable, we expand it (replacing the original node) + if (TestArgumentsManager.IsExpandableTestNode(currentNode)) + { + currentNode = await _testArgumentsManager.ExpandTestNodeAsync(currentNode); + } + + // If the node is not filtered out by the test execution filter, we call the callback with the node. + if (_testExecutionFilter is not TestNodeUidListFilter listFilter + || listFilter.TestNodeUids.Any(uid => currentNode.StableUid.ToPlatformTestNodeUid() == uid)) + { + await onIncludedTestNodeAsync(currentNode, parentNodeUid); + } + + foreach (TestNode childNode in currentNode.Tests) + { + queue.Enqueue((childNode, currentNode.StableUid, nodeFullPathForChildren)); + } + } + + DuplicatedNodes = testNodesByUid.Where(x => x.Value.Count > 1).ToArray(); + } + + private static PropertyBag CreatePropertyBagForFilter(IProperty[] properties) + { + PropertyBag propertyBag = new(); + foreach (IProperty property in properties) + { + propertyBag.Add(property); + } + + return propertyBag; + } + + private static string EncodeString(string value) + => HttpUtility.UrlEncode(value); +} diff --git a/src/Adapter/MSTest.Engine/Engine/ITestArgumentsEntry.cs b/src/Adapter/MSTest.Engine/Engine/ITestArgumentsEntry.cs new file mode 100644 index 0000000000..37a3a595c3 --- /dev/null +++ b/src/Adapter/MSTest.Engine/Engine/ITestArgumentsEntry.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +namespace Microsoft.Testing.Framework; + +internal interface ITestArgumentsEntry +{ + string UidFragment { get; } + + string? DisplayNameFragment { get; } + + object? Arguments { get; } +} diff --git a/src/Adapter/MSTest.Engine/Engine/ITestArgumentsManager.cs b/src/Adapter/MSTest.Engine/Engine/ITestArgumentsManager.cs new file mode 100644 index 0000000000..b02742fc7a --- /dev/null +++ b/src/Adapter/MSTest.Engine/Engine/ITestArgumentsManager.cs @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +namespace Microsoft.Testing.Framework; + +internal interface ITestArgumentsManager +{ + void RegisterTestArgumentsEntryProvider( + TestNodeUid testNodeStableUid, + Func> argumentsEntryProviderCallback); +} diff --git a/src/Adapter/MSTest.Engine/Engine/ITestExecutionContext.cs b/src/Adapter/MSTest.Engine/Engine/ITestExecutionContext.cs new file mode 100644 index 0000000000..1f451eb449 --- /dev/null +++ b/src/Adapter/MSTest.Engine/Engine/ITestExecutionContext.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +using Microsoft.Testing.Framework.Configurations; + +namespace Microsoft.Testing.Framework; + +public interface ITestExecutionContext +{ + CancellationToken CancellationToken { get; } + + IConfiguration Configuration { get; } + + ITestInfo TestInfo { get; } + + void CancelTestExecution(); + + void CancelTestExecution(int millisecondsDelay); + + void CancelTestExecution(TimeSpan delay); + + void ReportException(Exception exception, CancellationToken? timeoutCancellationToken = null); + + Task AddTestAttachmentAsync(FileInfo file, string displayName, string? description = null); +} diff --git a/src/Adapter/MSTest.Engine/Engine/ITestFixtureManager.cs b/src/Adapter/MSTest.Engine/Engine/ITestFixtureManager.cs new file mode 100644 index 0000000000..cb1508d837 --- /dev/null +++ b/src/Adapter/MSTest.Engine/Engine/ITestFixtureManager.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +namespace Microsoft.Testing.Framework; + +internal interface ITestFixtureManager +{ + public void RegisterFixture(string fixtureId, Func factory) + where TFixture : notnull; + + public void RegisterFixture(string fixtureId, Func> asyncFactory) + where TFixture : notnull; + + public void TryRegisterFixture(string fixtureId, Func factory) + where TFixture : notnull; + + public void TryRegisterFixture(string fixtureId, Func> asyncFactory) + where TFixture : notnull; + + Task GetFixtureAsync(string fixtureId) + where TFixture : notnull; +} diff --git a/src/Adapter/MSTest.Engine/Engine/ITestInfo.cs b/src/Adapter/MSTest.Engine/Engine/ITestInfo.cs new file mode 100644 index 0000000000..1349a163b8 --- /dev/null +++ b/src/Adapter/MSTest.Engine/Engine/ITestInfo.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +using Microsoft.Testing.Platform.Extensions.Messages; + +namespace Microsoft.Testing.Framework; + +public interface ITestInfo +{ + TestNodeUid StableUid { get; } + + string DisplayName { get; } + + IProperty[] Properties { get; } +} diff --git a/src/Adapter/MSTest.Engine/Engine/ITestSessionContext.cs b/src/Adapter/MSTest.Engine/Engine/ITestSessionContext.cs new file mode 100644 index 0000000000..4a5968e93b --- /dev/null +++ b/src/Adapter/MSTest.Engine/Engine/ITestSessionContext.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +using Microsoft.Testing.Framework.Configurations; + +namespace Microsoft.Testing.Framework; + +public interface ITestSessionContext +{ + CancellationToken CancellationToken { get; } + + IConfiguration Configuration { get; } + + Task AddTestAttachmentAsync(FileInfo file, string displayName, string? description = null); +} diff --git a/src/Adapter/MSTest.Engine/Engine/TestArgumentsContext.cs b/src/Adapter/MSTest.Engine/Engine/TestArgumentsContext.cs new file mode 100644 index 0000000000..b716cd2149 --- /dev/null +++ b/src/Adapter/MSTest.Engine/Engine/TestArgumentsContext.cs @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +namespace Microsoft.Testing.Framework; + +internal sealed class TestArgumentsContext(object arguments, TestNode target) +{ + public object Arguments { get; } = arguments; + + public TestNode Target { get; } = target; +} diff --git a/src/Adapter/MSTest.Engine/Engine/TestArgumentsEntry.cs b/src/Adapter/MSTest.Engine/Engine/TestArgumentsEntry.cs new file mode 100644 index 0000000000..5fc0c05f8b --- /dev/null +++ b/src/Adapter/MSTest.Engine/Engine/TestArgumentsEntry.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +namespace Microsoft.Testing.Framework; + +/// +/// WARNING: This type is public, but is meant for use only by MSTest source generator. Unannounced breaking changes to this API may happen. +/// +/// Type of the input data. +public sealed class InternalUnsafeTestArgumentsEntry(TArguments arguments, string uidFragment, string? displayNameFragment = null) : ITestArgumentsEntry +{ + public TArguments Arguments { get; } = arguments; + + public string UidFragment { get; } = uidFragment; + + public string? DisplayNameFragment { get; } = displayNameFragment; + + object? ITestArgumentsEntry.Arguments => Arguments; +} diff --git a/src/Adapter/MSTest.Engine/Engine/TestArgumentsManager.cs b/src/Adapter/MSTest.Engine/Engine/TestArgumentsManager.cs new file mode 100644 index 0000000000..1003fe27ef --- /dev/null +++ b/src/Adapter/MSTest.Engine/Engine/TestArgumentsManager.cs @@ -0,0 +1,158 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +using Microsoft.Testing.Platform; + +namespace Microsoft.Testing.Framework; + +internal sealed class TestArgumentsManager : ITestArgumentsManager +{ + private readonly Dictionary> _testArgumentsEntryProviders = new(); + private bool _isRegistrationFrozen; + + public void RegisterTestArgumentsEntryProvider( + TestNodeUid testNodeStableUid, + Func> argumentPropertiesProviderCallback) + { + if (_isRegistrationFrozen) + { + throw new InvalidOperationException("Cannot register TestArgumentsEntry provider after registration is frozen."); + } + + if (_testArgumentsEntryProviders.ContainsKey(testNodeStableUid)) + { + throw new InvalidOperationException($"TestArgumentsEntry provider is already registered for test node with UID '{testNodeStableUid}'."); + } + + _testArgumentsEntryProviders.Add(testNodeStableUid, argumentPropertiesProviderCallback); + } + + internal void FreezeRegistration() => _isRegistrationFrozen = true; + + internal static bool IsExpandableTestNode(TestNode testNode) + => testNode is IExpandableTestNode + && !testNode.Properties.OfType().SingleOrDefault().PreventArgumentsExpansion; + + internal async Task ExpandTestNodeAsync(TestNode currentNode) + { + RoslynDebug.Assert(IsExpandableTestNode(currentNode), "Test node is not expandable"); + + var expandableTestNode = (IExpandableTestNode)currentNode; + + int argumentsRowIndex = -1; + bool isIndexArgumentPropertiesProvider = false; + if (!_testArgumentsEntryProviders.TryGetValue( + currentNode.StableUid, + out Func? argumentPropertiesProvider)) + { + isIndexArgumentPropertiesProvider = true; + argumentPropertiesProvider = argument => + { + string fragment = $"[{argumentsRowIndex}]"; + return new InternalUnsafeTestArgumentsEntry(argument.Arguments, fragment, fragment); + }; + } + + HashSet expandedTestNodeUids = new(); + List expandedTestNodes = new(currentNode.Tests); + switch (currentNode) + { + case IParameterizedTestNode parameterizedTestNode: + foreach (object? arguments in parameterizedTestNode.GetArguments()) + { + ExpandNodeWithArguments(currentNode, arguments, ref argumentsRowIndex, expandedTestNodes, + expandedTestNodeUids, argumentPropertiesProvider, isIndexArgumentPropertiesProvider); + } + + break; + + case ITaskParameterizedTestNode parameterizedTestNode: + foreach (object? arguments in await parameterizedTestNode.GetArguments()) + { + ExpandNodeWithArguments(currentNode, arguments, ref argumentsRowIndex, expandedTestNodes, + expandedTestNodeUids, argumentPropertiesProvider, isIndexArgumentPropertiesProvider); + } + + break; + +#if NET + case IAsyncParameterizedTestNode parameterizedTestNode: + await foreach (object? arguments in parameterizedTestNode.GetArguments()) + { + ExpandNodeWithArguments(currentNode, arguments, ref argumentsRowIndex, expandedTestNodes, + expandedTestNodeUids, argumentPropertiesProvider, isIndexArgumentPropertiesProvider); + } + + break; +#endif + + default: + throw new InvalidOperationException($"Unexpected parameterized test node type: '{currentNode.GetType()}'"); + } + + // When the node is expandable, we need to create a new node that is not an action node, but a container + // node that contains the expanded nodes. This is this node that will be executed. + TestNode expandedNode = new() + { + StableUid = currentNode.StableUid, + DisplayName = currentNode.DisplayName, + OverriddenEdgeName = currentNode.OverriddenEdgeName, + Properties = currentNode.Properties, + Tests = expandedTestNodes.ToArray(), + }; + + return expandedNode; + + // Local functions + static void ExpandNodeWithArguments(TestNode testNode, object arguments, ref int argumentsRowIndex, List expandedTestNodes, + HashSet expandedTestNodeUids, Func argumentPropertiesProvider, + bool isIndexArgumentPropertiesProvider) + { + // We need to increase the index before calling the argumentPropertiesProvider, because it is capturing it's value + argumentsRowIndex++; + bool shouldWrapInParenthesis = true; + if (arguments is not ITestArgumentsEntry testArgumentsEntry) + { + shouldWrapInParenthesis = !isIndexArgumentPropertiesProvider; + testArgumentsEntry = argumentPropertiesProvider(new(arguments, testNode))!; + } + + string argumentFragmentUid = GetArgumentFragmentUid(testArgumentsEntry, shouldWrapInParenthesis); + string argumentFragmentDisplayName = GetArgumentFragmentDisplayName(testArgumentsEntry, shouldWrapInParenthesis); + TestNode expandedTestNode = ((IExpandableTestNode)testNode).GetExpandedTestNode(arguments, argumentFragmentUid, + argumentFragmentDisplayName); + if (!expandedTestNodeUids.Add(expandedTestNode.StableUid)) + { + throw new InvalidOperationException( + $"Expanded test node with UID '{expandedTestNode.StableUid}' is not unique for test node '{testNode.StableUid}'."); + } + + expandedTestNodes.Add(expandedTestNode); + } + + static string GetArgumentFragmentUid(ITestArgumentsEntry testArgumentsEntry, bool shouldWrapInParenthesis) + => CreateWrappedName(testArgumentsEntry.UidFragment, shouldWrapInParenthesis); + + static string GetArgumentFragmentDisplayName(ITestArgumentsEntry testArgumentsEntry, bool shouldWrapInParenthesis) + => CreateWrappedName(testArgumentsEntry.DisplayNameFragment ?? testArgumentsEntry.UidFragment, shouldWrapInParenthesis); + + static string CreateWrappedName(string name, bool shouldWrapInParenthesis) + { + StringBuilder displayNameBuilder = new(); + + if (shouldWrapInParenthesis) + { + displayNameBuilder.Append('('); + } + + displayNameBuilder.Append(name); + + if (shouldWrapInParenthesis) + { + displayNameBuilder.Append(')'); + } + + return displayNameBuilder.ToString(); + } + } +} diff --git a/src/Adapter/MSTest.Engine/Engine/TestContext.cs b/src/Adapter/MSTest.Engine/Engine/TestContext.cs new file mode 100644 index 0000000000..46d22337cd --- /dev/null +++ b/src/Adapter/MSTest.Engine/Engine/TestContext.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +namespace Microsoft.Testing.Framework; + +internal readonly struct TestContext(CancellationToken cancellationToken) +{ + public CancellationToken CancellationToken { get; } = cancellationToken; +} diff --git a/src/Adapter/MSTest.Engine/Engine/TestExecutionContext.cs b/src/Adapter/MSTest.Engine/Engine/TestExecutionContext.cs new file mode 100644 index 0000000000..8ea46d3754 --- /dev/null +++ b/src/Adapter/MSTest.Engine/Engine/TestExecutionContext.cs @@ -0,0 +1,128 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +using Microsoft.Testing.Extensions.TrxReport.Abstractions; +using Microsoft.Testing.Framework.Configurations; +using Microsoft.Testing.Framework.Helpers; +using Microsoft.Testing.Platform.Extensions.Messages; +using Microsoft.Testing.Platform.TestHost; + +using PlatformTestNode = Microsoft.Testing.Platform.Extensions.Messages.TestNode; + +namespace Microsoft.Testing.Framework; + +internal sealed class TestExecutionContext : ITestExecutionContext +{ + private readonly CancellationTokenSource _cancellationTokenSource; + private readonly PlatformTestNode _platformTestNode; + private readonly ITrxReportCapability? _trxReportCapability; + private readonly SessionUid _sessionUid; + private readonly Func _publishDataAsync; + private readonly CancellationToken _originalCancellationToken; + + public TestExecutionContext(IConfiguration configuration, TestNode testNode, PlatformTestNode platformTestNode, + ITrxReportCapability? trxReportCapability, SessionUid sessionUid, Func publishDataAsync, CancellationToken cancellationToken) + { + Configuration = configuration; + _platformTestNode = platformTestNode; + _trxReportCapability = trxReportCapability; + _sessionUid = sessionUid; + _publishDataAsync = publishDataAsync; + TestInfo = new TestInfo(testNode); + _cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + _originalCancellationToken = cancellationToken; + } + + public CancellationToken CancellationToken => _cancellationTokenSource.Token; + + public IConfiguration Configuration { get; } + + public ITestInfo TestInfo { get; } + + public void CancelTestExecution() + => _cancellationTokenSource.Cancel(); + + public void CancelTestExecution(int millisecondsDelay) + => _cancellationTokenSource.CancelAfter(millisecondsDelay); + + public void CancelTestExecution(TimeSpan delay) + => _cancellationTokenSource.CancelAfter(delay); + + public void ReportException(Exception exception, CancellationToken? timeoutCancellationToken = null) + { + if (_trxReportCapability is not null && _trxReportCapability.IsSupported) + { + AddTrxExceptionInformation(_platformTestNode.Properties, exception); + } + + TestNodeStateProperty executionState = exception switch + { + // We want to consider user timeouts as failures if they didn't use our cancellation token + OperationCanceledException canceledException + when canceledException.CancellationToken == _originalCancellationToken || canceledException.CancellationToken == CancellationToken + => new CancelledTestNodeStateProperty(ExceptionFlattener.FlattenOrUnwrap(exception)), + OperationCanceledException canceledException when canceledException.CancellationToken == timeoutCancellationToken + => new TimeoutTestNodeStateProperty(ExceptionFlattener.FlattenOrUnwrap(exception)), + AssertFailedException => new FailedTestNodeStateProperty(ExceptionFlattener.FlattenOrUnwrap(exception), exception.Message), + + // TODO: Filter exceptions that are to be considered as failures and return ErrorReason for the others + _ => new ErrorTestNodeStateProperty(exception), + }; + + // TODO: We need to be able to modify the execution state of a test node + if (!_platformTestNode.Properties.Any()) + { + _platformTestNode.Properties.Add(executionState); + } + } + + public async Task AddTestAttachmentAsync(FileInfo file, string displayName, string? description = null) + => await _publishDataAsync(new TestNodeFileArtifact(_sessionUid, _platformTestNode, file, displayName, description)); + + private static void AddTrxExceptionInformation(PropertyBag propertyBag, Exception? exception) + { + Exception? flatException = exception != null + ? ExceptionFlattener.FlattenOrUnwrap(exception) + : null; + if (flatException is null) + { + return; + } + + propertyBag.Add(new TrxExceptionProperty(StringifyMessage(flatException), StringifyStackTrace(flatException))); + + static string StringifyMessage(Exception exception) + { + string message = exception.Message; + if (exception.Data["assert.expected"] is string expected) + { + message += $"{Environment.NewLine}Expected:{Environment.NewLine}{expected}"; + } + + if (exception.Data["assert.actual"] is string actual) + { + message += $"{Environment.NewLine}Actual:{Environment.NewLine}{actual}"; + } + + return message; + } + + static string StringifyStackTrace(Exception exception) + { + if (exception is not AggregateException aggregateException) + { + return exception.StackTrace ?? string.Empty; + } + + string separator = "---End of inner exception ---"; + StringBuilder builder = new(); + foreach (Exception ex in aggregateException.InnerExceptions) + { + builder.AppendLine(ex.StackTrace); + builder.AppendLine(separator); + } + + return builder.ToString(); + } + } +} diff --git a/src/Adapter/MSTest.Engine/Engine/TestFixtureManager.cs b/src/Adapter/MSTest.Engine/Engine/TestFixtureManager.cs new file mode 100644 index 0000000000..4c26895bc4 --- /dev/null +++ b/src/Adapter/MSTest.Engine/Engine/TestFixtureManager.cs @@ -0,0 +1,229 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +using Microsoft.Testing.Framework.Helpers; +using Microsoft.Testing.Platform.Helpers; + +using Polyfills; + +namespace Microsoft.Testing.Framework; + +internal sealed class TestFixtureManager : ITestFixtureManager +{ + private readonly Dictionary>> _fixtureInstancesByFixtureId = new(); + private readonly Dictionary _fixtureIdsUsedByTestNode = new(); + + // We could improve this by doing some optimistic lock but we expect a rather low contention on this. + // We use a dictionary as performance improvement because we know that when the registration is complete + // we will only read the collection (so no need for concurrency handling). + private readonly Dictionary _fixtureUses = new(); + private readonly CancellationToken _cancellationToken; + private bool _isRegistrationFrozen; + private bool _isUsageRegistrationFrozen; + + public TestFixtureManager(CancellationToken cancellationToken) + => _cancellationToken = cancellationToken; + + public void RegisterFixture(string fixtureId, Func factory) + where TFixture : notnull + => TryRegisterFixture(fixtureId, () => Task.FromResult(factory()), + () => throw new InvalidOperationException($"Fixture with ID '{fixtureId}' is already registering a fixture of type {typeof(TFixture)}")); + + public void RegisterFixture(string fixtureId, Func> asyncFactory) + where TFixture : notnull + => TryRegisterFixture(fixtureId, asyncFactory, + () => throw new InvalidOperationException($"Fixture with ID '{fixtureId}' is already registering a fixture of type {typeof(TFixture)}")); + + public void TryRegisterFixture(string fixtureId, Func factory) + where TFixture : notnull + => TryRegisterFixture(fixtureId, () => Task.FromResult(factory()), () => { }); + + public void TryRegisterFixture(string fixtureId, Func> asyncFactory) + where TFixture : notnull + => TryRegisterFixture(fixtureId, asyncFactory, () => { }); + + public async Task GetFixtureAsync(string fixtureId) + where TFixture : notnull + { + Guard.NotNullOrWhiteSpace(fixtureId); + if (!_fixtureInstancesByFixtureId.TryGetValue(fixtureId, out Dictionary>? fixtureInstancesPerType)) + { + throw new InvalidOperationException($"Fixture with ID '{fixtureId}' is not registered"); + } + + if (!fixtureInstancesPerType.TryGetValue(typeof(TFixture), out AsyncLazy? fixture)) + { + throw new InvalidOperationException($"Fixture with ID '{fixtureId}' is not registered for type {typeof(TFixture)}"); + } + + if (!fixture.IsValueCreated || !fixture.Value.IsCompleted) + { + await fixture.Value; + } + + // We can safely cast here because we know that the fixture is of type TFixture and is awaited. +#pragma warning disable VSTHRD103 // Call async methods when in an async method + return (TFixture)fixture.Value.Result; +#pragma warning restore VSTHRD103 // Call async methods when in an async method + } + + internal void RegisterFixtureUsage(TestNode testNode, string[] fixtureIds) + { + if (_isUsageRegistrationFrozen) + { + throw new InvalidOperationException("Cannot register fixture usage after registration is frozen"); + } + + if (fixtureIds.Length == 0) + { + return; + } + + _fixtureIdsUsedByTestNode.Add(testNode, fixtureIds.Select(x => new FixtureId(x)).ToArray()); + foreach (string fixtureId in fixtureIds) + { + if (!_fixtureUses.TryGetValue(fixtureId, out CountHolder? uses)) + { + uses = new(); + _fixtureUses.Add(fixtureId, uses); + } + + uses.Value++; + } + } + + internal void FreezeRegistration() => _isRegistrationFrozen = true; + + internal void FreezeUsageRegistration() => _isUsageRegistrationFrozen = true; + + internal async Task SetupUsedFixturesAsync(TestNode testNode) + { + if (!_fixtureIdsUsedByTestNode.TryGetValue(testNode, out FixtureId[]? fixtureIds)) + { + return; + } + + foreach (FixtureId fixtureId in fixtureIds) + { + if (!_fixtureInstancesByFixtureId.TryGetValue(fixtureId, out Dictionary>? fixtureInstancesPerType)) + { + throw new InvalidOperationException($"Fixture with ID '{fixtureId}' is not registered"); + } + + foreach (AsyncLazy lazyFixture in fixtureInstancesPerType.Values) + { + if (!lazyFixture.IsValueCreated || !lazyFixture.Value.IsCompleted) + { + await lazyFixture.Value; + } + } + } + } + + internal async Task CleanUnusedFixturesAsync(TestNode testNode) + { + if (!_fixtureIdsUsedByTestNode.TryGetValue(testNode, out FixtureId[]? fixtureIds)) + { + return; + } + + foreach (FixtureId fixtureId in fixtureIds) + { + CountHolder uses = _fixtureUses[fixtureId]; + int usesCount = uses.Value; + lock (uses) + { + uses.Value--; + usesCount = uses.Value; + } + + // It's important to use the captured value and to not check `uses.Value` again because + // another thread could have decremented the value in the meantime. We would then end up + // cleaning the fixture multiple times. + if (usesCount == 0) + { + await CleanupAndDisposeFixtureAsync(fixtureId); + } + } + } + + private void TryRegisterFixture(string fixtureId, Func> asyncFactory, Action onTypeExist) + where TFixture : notnull + { + Guard.NotNullOrWhiteSpace(fixtureId); + + if (_isRegistrationFrozen) + { + throw new InvalidOperationException("Cannot register fixture usage after registration is frozen"); + } + + if (_fixtureInstancesByFixtureId.TryGetValue(fixtureId, out Dictionary>? fixtureInstancesPerType)) + { + if (fixtureInstancesPerType.ContainsKey(typeof(TFixture))) + { + onTypeExist(); + } + else + { + fixtureInstancesPerType.Add( + typeof(TFixture), + new(async () => await CreateAndInitializeFixtureAsync(asyncFactory, _cancellationToken), LazyThreadSafetyMode.ExecutionAndPublication)); + } + } + else + { + _fixtureInstancesByFixtureId.Add( + fixtureId, + new() + { + [typeof(TFixture)] = new( + async () => await CreateAndInitializeFixtureAsync(asyncFactory, _cancellationToken), + LazyThreadSafetyMode.ExecutionAndPublication), + }); + } + + static async Task CreateAndInitializeFixtureAsync(Func> asyncFactory, CancellationToken cancellationToken) + { + TFixture fixture = await asyncFactory(); + return fixture; + } + } + + private async Task CleanupAndDisposeFixtureAsync(FixtureId fixtureId) + { + if (!_fixtureInstancesByFixtureId.TryGetValue(fixtureId, out Dictionary>? fixtureInstancesPerType)) + { + throw new InvalidOperationException($"Fixture with ID '{fixtureId}' is not registered"); + } + + foreach (AsyncLazy lazyFixture in fixtureInstancesPerType.Values) + { + if (!lazyFixture.IsValueCreated || !lazyFixture.Value.IsCompleted) + { + throw new InvalidOperationException($"Fixture with ID '{fixtureId}' is not created"); + } + +#pragma warning disable VSTHRD103 // Call async methods when in an async method + object fixture = lazyFixture.Value.Result; +#pragma warning restore VSTHRD103 // Call async methods when in an async method + + await DisposeHelper.DisposeAsync(fixture); + } + } + + /// + /// Integers are value types and we need a reference type to be able to lock on it. + /// + private sealed class CountHolder + { +#pragma warning disable SA1401 // Fields should be private + public int Value; +#pragma warning restore SA1401 // Fields should be private + } + + private sealed record FixtureId(string Value) + { + public static implicit operator FixtureId(string fixtureId) + => new(fixtureId); + } +} diff --git a/src/Adapter/MSTest.Engine/Engine/TestFrameworkEngine.cs b/src/Adapter/MSTest.Engine/Engine/TestFrameworkEngine.cs new file mode 100644 index 0000000000..f78f930f7a --- /dev/null +++ b/src/Adapter/MSTest.Engine/Engine/TestFrameworkEngine.cs @@ -0,0 +1,209 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +using Microsoft.Testing.Framework.Adapter; +using Microsoft.Testing.Framework.Configurations; +using Microsoft.Testing.Framework.Helpers; +using Microsoft.Testing.Platform; +using Microsoft.Testing.Platform.Capabilities.TestFramework; +using Microsoft.Testing.Platform.Extensions.Messages; +using Microsoft.Testing.Platform.Helpers; +using Microsoft.Testing.Platform.Messages; +using Microsoft.Testing.Platform.Requests; + +using PlatformIConfiguration = Microsoft.Testing.Platform.Configurations.IConfiguration; +using PlatformTestNode = Microsoft.Testing.Platform.Extensions.Messages.TestNode; + +namespace Microsoft.Testing.Framework; + +internal sealed class TestFrameworkEngine : IDataProducer +{ + private readonly TestingFrameworkExtension _extension; + private readonly ITestFrameworkCapabilities _capabilities; + private readonly IClock _clock; + private readonly ITask _task; + private readonly TestFrameworkConfiguration _testFrameworkConfiguration; + private readonly ITestNodesBuilder[] _testNodesBuilders; + private readonly ConfigurationWrapper _configuration; + + public TestFrameworkEngine(TestFrameworkConfiguration testFrameworkConfiguration, ITestNodesBuilder[] testNodesBuilders, TestingFrameworkExtension extension, ITestFrameworkCapabilities capabilities, + IClock clock, ITask task, PlatformIConfiguration configuration) + { + _extension = extension; + _capabilities = capabilities; + _clock = clock; + _task = task; + _testFrameworkConfiguration = testFrameworkConfiguration; + _testNodesBuilders = testNodesBuilders; + _configuration = new(configuration); + } + + public Type[] DataTypesProduced { get; } + = new Type[1] { typeof(TestNodeUpdateMessage) }; + + public string Uid => _extension.Uid; + + public string Version => _extension.Version; + + public string DisplayName => _extension.DisplayName; + + public string Description => _extension.Description; + + public async Task IsEnabledAsync() => await _extension.IsEnabledAsync(); + + public async Task ExecuteRequestAsync(TestExecutionRequest testExecutionRequest, IMessageBus messageBus, CancellationToken cancellationToken) + => testExecutionRequest switch + { + DiscoverTestExecutionRequest discoveryRequest => await ExecuteTestNodeDiscoveryAsync(discoveryRequest, messageBus, cancellationToken), + RunTestExecutionRequest runRequest => await ExecuteTestNodeRunAsync(runRequest, messageBus, cancellationToken), + _ => Result.Fail($"Unexpected request type: '{testExecutionRequest.GetType().FullName}'"), + }; + + private async Task ExecuteTestNodeRunAsync(RunTestExecutionRequest request, IMessageBus messageBus, + CancellationToken cancellationToken) + { + List allRootTestNodes = new(); + TestFixtureManager fixtureManager = new(cancellationToken); + TestArgumentsManager argumentsManager = new(); + TestSessionContext testSessionContext = new(_configuration, fixtureManager, argumentsManager, request.Session.SessionUid, + PublishDataAsync, cancellationToken); + + try + { + foreach (ITestNodesBuilder testNodeBuilder in _testNodesBuilders) + { + TestNode[] testNodes = await testNodeBuilder.BuildAsync(testSessionContext); + allRootTestNodes.AddRange(testNodes); + } + + // We have built all test nodes, now we need to process them. Before that, we want to make sure to freeze managers + // to ensure that no new registrations are allowed. + fixtureManager.FreezeRegistration(); + argumentsManager.FreezeRegistration(); + + BFSTestNodeVisitor testNodesVisitor = new(allRootTestNodes, request.Filter, argumentsManager); + ThreadPoolTestNodeRunner testNodeRunner = new(_testFrameworkConfiguration, _capabilities, _clock, _task, _configuration, request.Session.SessionUid, + PublishDataAsync, fixtureManager, cancellationToken); + + await testNodesVisitor.VisitAsync((testNode, parentTestNodeUid) => + { + if (testNode is IActionableTestNode) + { + testNodeRunner.EnqueueTest(testNode, parentTestNodeUid); + } + + string[] fixtureIds = testNode.Properties + .OfType() + .SingleOrDefault() + .UsedFixtureIds + ?? Array.Empty(); + fixtureManager.RegisterFixtureUsage(testNode, fixtureIds); + + return Task.CompletedTask; + }); + + if (testNodesVisitor.DuplicatedNodes.Length > 0) + { + StringBuilder errorMessageBuilder = new(); + errorMessageBuilder.AppendLine("Found multiple test nodes with the same UID:"); + foreach (KeyValuePair> duplicate in testNodesVisitor.DuplicatedNodes) + { + errorMessageBuilder.Append(CultureInfo.InvariantCulture, $"- For {duplicate.Key}: tests "); + errorMessageBuilder.AppendLine(string.Join(", ", duplicate.Value.Select(x => x.DisplayName))); + } + + return Result.Fail(errorMessageBuilder.ToString()); + } + + // Then, we want to freeze the registration of the fixtures, so that we can't add new fixtures. + fixtureManager.FreezeUsageRegistration(); + testNodeRunner.StartTests(); + + // Finally, we want to wait for all tests to complete. + return await testNodeRunner.WaitAllTestsAsync(cancellationToken); + } + finally + { + foreach (ITestNodesBuilder testNodeBuilder in _testNodesBuilders) + { + await DisposeHelper.DisposeAsync(testNodeBuilder); + } + } + + // Local functions + Task PublishDataAsync(IData data) + { + RoslynDebug.Assert(DataTypesProduced.Contains(data.GetType()), "Published data type hasn't been declared"); + + return messageBus.PublishAsync(this, data); + } + } + + private async Task ExecuteTestNodeDiscoveryAsync(DiscoverTestExecutionRequest request, IMessageBus messageBus, + CancellationToken cancellationToken) + { + List allRootTestNodes = new(); + TestFixtureManager fixtureManager = new(cancellationToken); + TestArgumentsManager argumentsManager = new(); + TestSessionContext testSessionContext = new(_configuration, fixtureManager, argumentsManager, request.Session.SessionUid, + PublishDataAsync, cancellationToken); + + try + { + foreach (ITestNodesBuilder testNodeBuilder in _testNodesBuilders) + { + TestNode[] testNodes = await testNodeBuilder.BuildAsync(testSessionContext); + allRootTestNodes.AddRange(testNodes); + } + + // We have built all test nodes, now we need to process them. Before that, we want to make sure to freeze managers + // to ensure that no new registrations are allowed. + fixtureManager.FreezeRegistration(); + argumentsManager.FreezeRegistration(); + + BFSTestNodeVisitor testNodesVisitor = new(allRootTestNodes, request.Filter, argumentsManager); + + await testNodesVisitor.VisitAsync(async (testNode, parentTestNodeUid) => + { + PlatformTestNode progressNode = testNode.ToPlatformTestNode(); + if (testNode is IActionableTestNode) + { + progressNode.Properties.Add(DiscoveredTestNodeStateProperty.CachedInstance); + } + + await messageBus.PublishAsync(this, new TestNodeUpdateMessage(request.Session.SessionUid, progressNode, + parentTestNodeUid?.ToPlatformTestNodeUid())); + }); + + if (testNodesVisitor.DuplicatedNodes.Length > 0) + { + StringBuilder errorMessageBuilder = new(); + errorMessageBuilder.AppendLine("Found multiple test nodes with the same UID:"); + foreach (KeyValuePair> duplicate in testNodesVisitor.DuplicatedNodes) + { + errorMessageBuilder.Append(CultureInfo.InvariantCulture, $"- For {duplicate.Key}: tests "); + errorMessageBuilder.AppendLine(string.Join(", ", duplicate.Value.Select(x => x.DisplayName))); + } + + return Result.Fail(errorMessageBuilder.ToString()); + } + } + finally + { + foreach (ITestNodesBuilder testNodeBuilder in _testNodesBuilders) + { + await DisposeHelper.DisposeAsync(testNodeBuilder); + } + } + + return Result.Ok(); + + // Local functions + Task PublishDataAsync(IData data) + { + RoslynDebug.Assert(DataTypesProduced.Contains(data.GetType()), "Published data type hasn't been declared"); + + return messageBus.PublishAsync(this, data); + } + } +} diff --git a/src/Adapter/MSTest.Engine/Engine/TestInfo.cs b/src/Adapter/MSTest.Engine/Engine/TestInfo.cs new file mode 100644 index 0000000000..fd30f6ae21 --- /dev/null +++ b/src/Adapter/MSTest.Engine/Engine/TestInfo.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +using Microsoft.Testing.Platform.Extensions.Messages; + +namespace Microsoft.Testing.Framework; + +internal sealed class TestInfo(TestNode testNode) : ITestInfo +{ + public TestNodeUid StableUid => TestNode.StableUid; + + public string DisplayName => TestNode.DisplayName; + + public IProperty[] Properties => TestNode.Properties; + + public TestNode TestNode { get; } = testNode; +} diff --git a/src/Adapter/MSTest.Engine/Engine/TestSessionContext.cs b/src/Adapter/MSTest.Engine/Engine/TestSessionContext.cs new file mode 100644 index 0000000000..8db024032c --- /dev/null +++ b/src/Adapter/MSTest.Engine/Engine/TestSessionContext.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +using Microsoft.Testing.Framework.Configurations; +using Microsoft.Testing.Platform.Extensions.Messages; +using Microsoft.Testing.Platform.TestHost; + +namespace Microsoft.Testing.Framework; + +internal sealed class TestSessionContext : ITestSessionContext +{ + private readonly SessionUid _sessionUid; + private readonly Func _publishDataAsync; + + public TestSessionContext(IConfiguration configuration, ITestFixtureManager _, ITestArgumentsManager _2, + SessionUid sessionUid, Func publishDataAsync, CancellationToken cancellationToken) + { + Configuration = configuration; + + _sessionUid = sessionUid; + _publishDataAsync = publishDataAsync; + CancellationToken = cancellationToken; + } + + public CancellationToken CancellationToken { get; } + + public IConfiguration Configuration { get; } + + public async Task AddTestAttachmentAsync(FileInfo file, string displayName, string? description = null) + => await _publishDataAsync(new SessionFileArtifact(_sessionUid, file, displayName, description)); +} diff --git a/src/Adapter/MSTest.Engine/Engine/ThreadPoolTestNodeRunner.cs b/src/Adapter/MSTest.Engine/Engine/ThreadPoolTestNodeRunner.cs new file mode 100644 index 0000000000..75db5071dc --- /dev/null +++ b/src/Adapter/MSTest.Engine/Engine/ThreadPoolTestNodeRunner.cs @@ -0,0 +1,258 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +using Microsoft.Testing.Extensions.TrxReport.Abstractions; +using Microsoft.Testing.Framework.Configurations; +using Microsoft.Testing.Framework.Helpers; +using Microsoft.Testing.Platform.Capabilities.TestFramework; +using Microsoft.Testing.Platform.Extensions.Messages; +using Microsoft.Testing.Platform.Helpers; +using Microsoft.Testing.Platform.TestHost; + +using PlatformTestNode = Microsoft.Testing.Platform.Extensions.Messages.TestNode; + +namespace Microsoft.Testing.Framework; + +internal sealed class ThreadPoolTestNodeRunner : IDisposable +{ + private readonly SemaphoreSlim? _maxParallelTests; + private readonly ConcurrentBag> _runningTests = new(); + private readonly ConcurrentDictionary _runningTestNodeUids = new(); + private readonly CountdownEvent _ensureTaskQueuedCountdownEvent = new(1); + private readonly Func _publishDataAsync; + private readonly TestFixtureManager _testFixtureManager; + private readonly CancellationToken _cancellationToken; + private readonly IClock _clock; + private readonly IConfiguration _configuration; + private readonly SessionUid _sessionUid; + private readonly ITask _task; + private readonly ITrxReportCapability? _trxReportCapability; + private readonly TaskCompletionSource _waitForStart = new(); + private bool _isDisposed; + + public ThreadPoolTestNodeRunner(TestFrameworkConfiguration testFrameworkConfiguration, ITestFrameworkCapabilities capabilities, IClock clock, ITask task, IConfiguration configuration, + SessionUid sessionUid, Func publishDataAsync, TestFixtureManager testFixtureManager, + CancellationToken cancellationToken) + { + _clock = clock; + _configuration = configuration; + _sessionUid = sessionUid; + _publishDataAsync = publishDataAsync; + _testFixtureManager = testFixtureManager; + _cancellationToken = cancellationToken; + _task = task; + _trxReportCapability = capabilities.GetCapability(); + if (testFrameworkConfiguration.MaxParallelTests != int.MaxValue) + { + _maxParallelTests = new SemaphoreSlim(testFrameworkConfiguration.MaxParallelTests); + } + + cancellationToken.Register(_waitForStart.SetCanceled); + } + + public void EnqueueTest(TestNode frameworkTestNode, TestNodeUid? parentTestNodeUid) + { + _ensureTaskQueuedCountdownEvent.AddCount(); + try + { + _runningTests.Add( + _task.Run( + async () => + { + try + { + // We don't have a timeout here because we can have really slow fixture and it's on user + // the decision on how much to wait for it. + await _waitForStart.Task; + + // Handle the global parallelism. + if (_maxParallelTests is not null) + { + await _maxParallelTests.WaitAsync(); + } + + try + { + _runningTestNodeUids.AddOrUpdate(frameworkTestNode.StableUid, 1, (_, count) => count + 1); + + PlatformTestNode progressNode = frameworkTestNode.ToPlatformTestNode(); + progressNode.Properties.Add(InProgressTestNodeStateProperty.CachedInstance); + await _publishDataAsync(new TestNodeUpdateMessage(_sessionUid, progressNode, parentTestNodeUid?.ToPlatformTestNodeUid())); + + Result result = await CreateTestRunTaskAsync(frameworkTestNode, parentTestNodeUid); + + _runningTestNodeUids.TryRemove(frameworkTestNode.StableUid, out int count); + + return count > 1 + ? throw new InvalidOperationException($"Test node '{frameworkTestNode.StableUid}' was run {count} times") + : result; + } + finally + { + _maxParallelTests?.Release(); + } + } + catch (Exception ex) + { + Environment.FailFast($"Unhandled exception inside '{nameof(CreateTestRunTaskAsync)}'", ex); + throw; + } + }, + _cancellationToken)); + } + catch (OperationCanceledException ex) when (ex.CancellationToken == _cancellationToken) + { + // We are being cancelled, so we don't need to wait anymore + return; + } + finally + { + // We will signal for the second counting inside CreateTestRunTaskAsync() after the test run. + _ensureTaskQueuedCountdownEvent.Signal(); + } + } + + public void StartTests() + => _waitForStart.SetResult(0); + + private async Task CreateTestRunTaskAsync(TestNode testNode, TestNodeUid? parentTestNodeUid) + { + try + { + await _testFixtureManager.SetupUsedFixturesAsync(testNode); + } + catch (Exception ex) + { + StringBuilder errorBuilder = new(); + errorBuilder.AppendLine(CultureInfo.InvariantCulture, $"Error while initializing fixtures for test '{testNode.DisplayName}' (UID = {testNode.StableUid.Value})"); + errorBuilder.AppendLine(); + errorBuilder.AppendLine(ex.ToString()); + return Result.Fail(errorBuilder.ToString()); + } + + Result result = await InvokeTestNodeAndPublishResultAsync(testNode, parentTestNodeUid, + async (testNode, testExecutionContext) => + { + switch (testNode) + { + case IAsyncActionTestNode actionTestNode: + await actionTestNode.InvokeAsync(testExecutionContext); + break; + + case IActionTestNode actionTestNode: + actionTestNode.Invoke(testExecutionContext); + break; + + case IParameterizedAsyncActionTestNode actionTestNode: + await actionTestNode.InvokeAsync( + testExecutionContext, + action => InvokeTestNodeAndPublishResultAsync(testNode, parentTestNodeUid, (_, _) => action(), skipPublishResult: false)); + break; + + default: + break; + } + }, + // Because parameterized tests report multiple results (one per parameter set), we don't want to publish the result + // of the overall test node execution, but only the results of the individual parameterized tests. + skipPublishResult: testNode is IParameterizedAsyncActionTestNode); + + // Try to cleanup the fixture is not more used. + try + { + await _testFixtureManager.CleanUnusedFixturesAsync(testNode); + return result; + } + catch (Exception ex) + { + StringBuilder errorBuilder = new(); + errorBuilder.AppendLine(CultureInfo.InvariantCulture, $"Error while cleaning fixtures for test '{testNode.StableUid}'"); + errorBuilder.AppendLine(); + errorBuilder.AppendLine(ex.ToString()); + return result.WithError(errorBuilder.ToString()); + } + } + + public async Task WaitAllTestsAsync(CancellationToken cancellationToken) + { + try + { + _ensureTaskQueuedCountdownEvent.Signal(); + await _ensureTaskQueuedCountdownEvent.WaitAsync(cancellationToken); + Result[] results = await Task.WhenAll(_runningTests); + return Result.Combine(results); + } + catch (OperationCanceledException ex) when (ex.CancellationToken == cancellationToken) + { + // If the cancellation token is triggered, we don't want to report the cancellation as a failure + return Result.Ok("Cancelled by user"); + } + catch + { + throw; + } + } + + public void Dispose() + { + if (!_isDisposed) + { + _ensureTaskQueuedCountdownEvent.Dispose(); + _isDisposed = true; + } + } + + private async Task InvokeTestNodeAndPublishResultAsync(TestNode testNode, TestNodeUid? parentTestNodeUid, + Func testNodeInvokeAction, bool skipPublishResult) + { + TimeSheet timesheet = new(_clock); + timesheet.RecordStart(); + + PlatformTestNode platformTestNode = testNode.ToPlatformTestNode(); + + if (_trxReportCapability is not null && _trxReportCapability.IsSupported) + { + platformTestNode.Properties.Add(new TrxFullyQualifiedTypeNameProperty(platformTestNode.Uid.Value[..platformTestNode.Uid.Value.LastIndexOf('.')])); + } + + TestExecutionContext testExecutionContext = new(_configuration, testNode, platformTestNode, _trxReportCapability, _sessionUid, _publishDataAsync, _cancellationToken); + try + { + // If we're already enqueued we cancel the test before the start + // The test could not use the cancellation and we should wait the end of the test self to cancel. + _cancellationToken.ThrowIfCancellationRequested(); + await testNodeInvokeAction(testNode, testExecutionContext); + + if (!platformTestNode.Properties.Any()) + { + platformTestNode.Properties.Add(PassedTestNodeStateProperty.CachedInstance); + } + } + catch (MissingMethodException ex) + { + // In dotnet watch mode we can remove tests. + if (Environment.GetEnvironmentVariable("DOTNET_WATCH") == "1") + { + return Result.Ok().WithWarning("Under 'DOTNET_WATCH' cannot find some member." + Environment.NewLine + ex.StackTrace); + } + + throw; + } + catch (Exception ex) + { + testExecutionContext.ReportException(ex); + } + finally + { + timesheet.RecordStop(); + platformTestNode.Properties.Add(new TimingProperty(new TimingInfo(timesheet.StartTime, timesheet.StopTime, timesheet.Duration))); + } + + if (!skipPublishResult) + { + await _publishDataAsync(new TestNodeUpdateMessage(_sessionUid, platformTestNode, parentTestNodeUid?.ToPlatformTestNodeUid())); + } + + return Result.Ok(); + } +} diff --git a/src/Adapter/MSTest.Engine/Helpers/AsyncLazy.cs b/src/Adapter/MSTest.Engine/Helpers/AsyncLazy.cs new file mode 100644 index 0000000000..2d41e7d5d3 --- /dev/null +++ b/src/Adapter/MSTest.Engine/Helpers/AsyncLazy.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +namespace Microsoft.Testing.Framework.Helpers; + +internal sealed class AsyncLazy : Lazy> +{ + public AsyncLazy(Func valueFactory, LazyThreadSafetyMode mode) + : base(() => Task.Run(valueFactory), mode) + { + } + + public AsyncLazy(Func> taskFactory, LazyThreadSafetyMode mode) + : base(() => Task.Factory.StartNew(() => taskFactory(), CancellationToken.None, TaskCreationOptions.None, TaskScheduler.Default).Unwrap(), mode) + { + } + + public TaskAwaiter GetAwaiter() => Value.GetAwaiter(); +} diff --git a/src/Adapter/MSTest.Engine/Helpers/DynamicDataNameProvider.cs b/src/Adapter/MSTest.Engine/Helpers/DynamicDataNameProvider.cs new file mode 100644 index 0000000000..705e34b296 --- /dev/null +++ b/src/Adapter/MSTest.Engine/Helpers/DynamicDataNameProvider.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +namespace Microsoft.Testing.Framework; + +/// +/// Helper to provide a uids to user data, using the same logic that DynamicDataAttribute.GetDisplayName is using. This class is called by source generator. +/// +public static class DynamicDataNameProvider +{ + /// + /// Returns a stable fragment of uid by converting parameter types to strings, and suffixing them with index in brackets (e.g. [1]). + /// + /// Names of the parameters of the receiving method. + /// The data for each parameter. + /// Position in the collection. + /// Stable uid. + /// Arrays in both parameters need to have the same number of items. + public static string GetUidFragment(string[] parameterNames, object?[] data, int index) + { + if (parameterNames.Length != data.Length) + { + throw new ArgumentException($"Parameter count mismatch. The provided data ({string.Join(", ", data.Select(d => d?.ToString() ?? "null"))}) have {data.Length} items, but there are {parameterNames.Length} parameters."); + } + + StringBuilder stringBuilder = new StringBuilder().Append('('); + + for (int i = 0; i < data.Length; i++) + { + if (i > 0) + { + stringBuilder.Append(", "); + } + + stringBuilder.Append(parameterNames[i]).Append(": ").Append(data[i]?.ToString() ?? "null"); + } + + stringBuilder.Append(CultureInfo.InvariantCulture, $")[{index}]"); + return stringBuilder.ToString(); + } +} diff --git a/src/Adapter/MSTest.Engine/Helpers/ErrorReason.cs b/src/Adapter/MSTest.Engine/Helpers/ErrorReason.cs new file mode 100644 index 0000000000..8a4a7cb30d --- /dev/null +++ b/src/Adapter/MSTest.Engine/Helpers/ErrorReason.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +namespace Microsoft.Testing.Framework; + +internal sealed class ErrorReason(string message) : IErrorReason +{ + internal ErrorReason(string message, Exception exception) + : this(message) + => Exception = exception; + + internal ErrorReason(Exception exception) + : this(exception.Message) + => Exception = exception; + + public Exception? Exception { get; } + + public string Message { get; } = message; +} diff --git a/src/Adapter/MSTest.Engine/Helpers/ExceptionFlattener.cs b/src/Adapter/MSTest.Engine/Helpers/ExceptionFlattener.cs new file mode 100644 index 0000000000..df26732702 --- /dev/null +++ b/src/Adapter/MSTest.Engine/Helpers/ExceptionFlattener.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +namespace Microsoft.Testing.Framework.Helpers; + +internal static class ExceptionFlattener +{ + /// + /// Returns the same exception for any exception that is not AggregateException. + /// For AggregateException it unwraps it when it holds just a single concrete exception, + /// otherwise it flattens the AggregateException and returns that. + /// + public static Exception FlattenOrUnwrap(Exception exception) + { + if (exception is AggregateException aggregateException) + { + if (aggregateException.InnerExceptions.Count == 1) + { + Exception innerException = aggregateException.InnerExceptions[0]; + if (innerException is not AggregateException) + { + return innerException; + } + } + + return aggregateException.Flatten(); + } + + return exception; + } +} diff --git a/src/Adapter/MSTest.Engine/Helpers/IErrorReason.cs b/src/Adapter/MSTest.Engine/Helpers/IErrorReason.cs new file mode 100644 index 0000000000..59d47b13ed --- /dev/null +++ b/src/Adapter/MSTest.Engine/Helpers/IErrorReason.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +namespace Microsoft.Testing.Framework; + +internal interface IErrorReason : IReason +{ + Exception? Exception { get; } +} diff --git a/src/Adapter/MSTest.Engine/Helpers/IReason.cs b/src/Adapter/MSTest.Engine/Helpers/IReason.cs new file mode 100644 index 0000000000..5f7f5a296c --- /dev/null +++ b/src/Adapter/MSTest.Engine/Helpers/IReason.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +namespace Microsoft.Testing.Framework; + +internal interface IReason +{ + string Message { get; } +} diff --git a/src/Adapter/MSTest.Engine/Helpers/ISuccessReason.cs b/src/Adapter/MSTest.Engine/Helpers/ISuccessReason.cs new file mode 100644 index 0000000000..e9f739f4e5 --- /dev/null +++ b/src/Adapter/MSTest.Engine/Helpers/ISuccessReason.cs @@ -0,0 +1,8 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +namespace Microsoft.Testing.Framework; + +internal interface ISuccessReason : IReason +{ +} diff --git a/src/Adapter/MSTest.Engine/Helpers/IWarningReason.cs b/src/Adapter/MSTest.Engine/Helpers/IWarningReason.cs new file mode 100644 index 0000000000..7b38a5ea9c --- /dev/null +++ b/src/Adapter/MSTest.Engine/Helpers/IWarningReason.cs @@ -0,0 +1,8 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +namespace Microsoft.Testing.Framework; + +internal interface IWarningReason : IReason +{ +} diff --git a/src/Adapter/MSTest.Engine/Helpers/Result.cs b/src/Adapter/MSTest.Engine/Helpers/Result.cs new file mode 100644 index 0000000000..6f6a70af1d --- /dev/null +++ b/src/Adapter/MSTest.Engine/Helpers/Result.cs @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +namespace Microsoft.Testing.Framework; + +internal sealed class Result +{ + private readonly List _reasons = new(); + + private Result() + { + } + + public bool IsSuccess => !IsFailed; + + public bool IsFailed => _reasons.OfType().Any(); + + public IReadOnlyList Reasons => _reasons; + + public Result WithSuccess(ISuccessReason success) + { + _reasons.Add(success); + return this; + } + + public Result WithWarning(IWarningReason warning) + { + _reasons.Add(warning); + return this; + } + + public Result WithError(IErrorReason error) + { + _reasons.Add(error); + return this; + } + + public static Result Ok() => new(); + + public static Result Ok(string reason) => new Result().WithSuccess(new SuccessReason(reason)); + + public static Result Fail(Exception exception) => new Result().WithError(new ErrorReason(exception)); + + public static Result Fail(string reason) => new Result().WithError(new ErrorReason(reason)); + + public static Result Fail(string reason, Exception exception) => new Result().WithError(new ErrorReason(reason, exception)); + + public static Result Combine(IEnumerable results) + { + Result result = Ok(); + foreach (Result r in results) + { + result._reasons.AddRange(r.Reasons); + } + + return result; + } +} diff --git a/src/Adapter/MSTest.Engine/Helpers/ResultExtensions.cs b/src/Adapter/MSTest.Engine/Helpers/ResultExtensions.cs new file mode 100644 index 0000000000..19aa6962b7 --- /dev/null +++ b/src/Adapter/MSTest.Engine/Helpers/ResultExtensions.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +namespace Microsoft.Testing.Framework; + +internal static class ResultExtensions +{ + public static Result WithWarning(this Result result, string message) + => result.WithWarning(new WarningReason(message)); + + public static Result WithError(this Result result, string message) + => result.WithError(new ErrorReason(message)); + + public static Result WithError(this Result result, Exception exception) + => result.WithError(new ErrorReason(exception)); +} diff --git a/src/Adapter/MSTest.Engine/Helpers/SuccessReason.cs b/src/Adapter/MSTest.Engine/Helpers/SuccessReason.cs new file mode 100644 index 0000000000..ffea736c93 --- /dev/null +++ b/src/Adapter/MSTest.Engine/Helpers/SuccessReason.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +namespace Microsoft.Testing.Framework; + +internal sealed class SuccessReason(string message) : ISuccessReason +{ + public string Message { get; } = message; +} diff --git a/src/Adapter/MSTest.Engine/Helpers/TestApplicationBuilderExtensions.cs b/src/Adapter/MSTest.Engine/Helpers/TestApplicationBuilderExtensions.cs new file mode 100644 index 0000000000..429637c388 --- /dev/null +++ b/src/Adapter/MSTest.Engine/Helpers/TestApplicationBuilderExtensions.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +using Microsoft.Testing.Framework.Adapter; +using Microsoft.Testing.Framework.Configurations; +using Microsoft.Testing.Platform.Builder; +using Microsoft.Testing.Platform.Helpers; +using Microsoft.Testing.Platform.Services; + +using Polyfills; + +namespace Microsoft.Testing.Framework; + +public static class TestApplicationBuilderExtensions +{ + public static void AddTestFramework(this ITestApplicationBuilder testApplicationBuilder, params ITestNodesBuilder[] testNodesBuilder) + => testApplicationBuilder.AddTestFramework(new(), testNodesBuilder); + + public static void AddTestFramework( + this ITestApplicationBuilder testApplicationBuilder, + TestFrameworkConfiguration? testFrameworkConfiguration = null, + params ITestNodesBuilder[] testNodesBuilder) + { + Guard.NotNull(testApplicationBuilder); + Guard.NotNull(testNodesBuilder); + ArgumentGuard.Ensure(testNodesBuilder.Length != 0, nameof(testNodesBuilder), + "At least one test node builder must be provided."); + + testFrameworkConfiguration ??= new TestFrameworkConfiguration(); + TestingFrameworkExtension extension = new(); + testApplicationBuilder.AddTreeNodeFilterService(extension); + testApplicationBuilder.RegisterTestFramework( + _ => new TestFrameworkCapabilities(testNodesBuilder), + (capabilities, serviceProvider) => + new TestFramework(testFrameworkConfiguration, testNodesBuilder, extension, serviceProvider.GetSystemClock(), + serviceProvider.GetTask(), serviceProvider.GetConfiguration(), capabilities)); + } +} diff --git a/src/Adapter/MSTest.Engine/Helpers/TestFrameworkConstants.cs b/src/Adapter/MSTest.Engine/Helpers/TestFrameworkConstants.cs new file mode 100644 index 0000000000..6523d05f7f --- /dev/null +++ b/src/Adapter/MSTest.Engine/Helpers/TestFrameworkConstants.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +namespace Microsoft.Testing.Framework; + +internal static class TestFrameworkConstants +{ + public const string DefaultSemVer = MSTestEngineRepositoryVersion.Version; + public const string TestAdapterExecutorUri = "executor://testing-framework"; +} diff --git a/src/Adapter/MSTest.Engine/Helpers/TestNodeExpansionHelper.cs b/src/Adapter/MSTest.Engine/Helpers/TestNodeExpansionHelper.cs new file mode 100644 index 0000000000..8551be94a4 --- /dev/null +++ b/src/Adapter/MSTest.Engine/Helpers/TestNodeExpansionHelper.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +namespace Microsoft.Testing.Framework.Helpers; + +internal static class TestNodeExpansionHelper +{ + public static TestNodeUid GenerateStableUid(TestNodeUid testNodeUid, string dataId) + => new($"{testNodeUid.Value} {dataId}"); + + public static string GenerateDisplayName(string displayName, string dataId) + => $"{displayName} {dataId}"; +} diff --git a/src/Adapter/MSTest.Engine/Helpers/TestNodeExtensions.cs b/src/Adapter/MSTest.Engine/Helpers/TestNodeExtensions.cs new file mode 100644 index 0000000000..a62946b038 --- /dev/null +++ b/src/Adapter/MSTest.Engine/Helpers/TestNodeExtensions.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +using Microsoft.Testing.Platform.Extensions.Messages; + +using PlatformTestNode = Microsoft.Testing.Platform.Extensions.Messages.TestNode; +using PlatformTestNodeUid = Microsoft.Testing.Platform.Extensions.Messages.TestNodeUid; + +namespace Microsoft.Testing.Framework.Helpers; + +internal static class TestNodeExtensions +{ + public static PlatformTestNode ToPlatformTestNode(this TestNode testNode) + { + if (testNode.DisplayName is null) + { + throw new ArgumentException("TestNode must have a DisplayNameFragment", nameof(testNode)); + } + + if (testNode.StableUid is null) + { + throw new ArgumentException("TestNode must have a StableUid", nameof(testNode)); + } + + var platformTestNode = new PlatformTestNode + { + Uid = testNode.StableUid.ToPlatformTestNodeUid(), + DisplayName = testNode.DisplayName, + }; + + foreach (IProperty property in testNode.Properties) + { + platformTestNode.Properties.Add(property); + } + + return platformTestNode; + } + + public static PlatformTestNodeUid ToPlatformTestNodeUid(this TestNodeUid testNodeUid) + => new(testNodeUid.Value); +} diff --git a/src/Adapter/MSTest.Engine/Helpers/WarningReason.cs b/src/Adapter/MSTest.Engine/Helpers/WarningReason.cs new file mode 100644 index 0000000000..9d6013fc64 --- /dev/null +++ b/src/Adapter/MSTest.Engine/Helpers/WarningReason.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +namespace Microsoft.Testing.Framework; + +internal sealed class WarningReason(string message) : IWarningReason +{ + public string Message { get; } = message; +} diff --git a/src/Adapter/MSTest.Engine/MSTest.Engine.csproj b/src/Adapter/MSTest.Engine/MSTest.Engine.csproj new file mode 100644 index 0000000000..6da901454d --- /dev/null +++ b/src/Adapter/MSTest.Engine/MSTest.Engine.csproj @@ -0,0 +1,111 @@ + + + + netstandard2.0;$(MicrosoftTestingTargetFrameworks) + Microsoft.Testing.Framework + + + License.txt + + 1.0.0 + alpha + true + + $(NoWarn);CS1591 + + + + + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + true + buildMultiTargeting + + + + build/$(TargetFramework) + + + + + + + + + + <_TemplateProperties>Version=$(Version) + + + <_TemplateCsproj Include="$(MSBuildProjectDirectory)/RepositoryVersion.cs.template" Destination="$(IntermediateOutputPath)/RepositoryVersion.cs" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Adapter/MSTest.Engine/PACKAGE.md b/src/Adapter/MSTest.Engine/PACKAGE.md new file mode 100644 index 0000000000..135a747edc --- /dev/null +++ b/src/Adapter/MSTest.Engine/PACKAGE.md @@ -0,0 +1,11 @@ +# Microsoft.Testing + +Microsoft Testing is a set of platform, framework and protocol intended to make it possible to run any test on any target or device. + +Documentation can be found at . + +## About + +This package provides the Microsoft specific Test Framework. + +Test Anywhere Test Framework is the first test framework to offer support for Native AOT and trimming scenarios. It is designed to avoid reflection and leverage modern tooling such as source generators. diff --git a/src/Adapter/MSTest.Engine/PublicAPI/PublicAPI.Shipped.txt b/src/Adapter/MSTest.Engine/PublicAPI/PublicAPI.Shipped.txt new file mode 100644 index 0000000000..7dc5c58110 --- /dev/null +++ b/src/Adapter/MSTest.Engine/PublicAPI/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/Adapter/MSTest.Engine/PublicAPI/PublicAPI.Unshipped.txt b/src/Adapter/MSTest.Engine/PublicAPI/PublicAPI.Unshipped.txt new file mode 100644 index 0000000000..99df4f868b --- /dev/null +++ b/src/Adapter/MSTest.Engine/PublicAPI/PublicAPI.Unshipped.txt @@ -0,0 +1,98 @@ +#nullable enable +Microsoft.Testing.Framework.AssertFailedException +Microsoft.Testing.Framework.AssertFailedException.AssertFailedException() -> void +Microsoft.Testing.Framework.AssertFailedException.AssertFailedException(string! message) -> void +Microsoft.Testing.Framework.AssertFailedException.AssertFailedException(string! message, System.Exception! ex) -> void +Microsoft.Testing.Framework.Configurations.ConfigurationExtensions +Microsoft.Testing.Framework.Configurations.IConfiguration +Microsoft.Testing.Framework.Configurations.IConfiguration.this[string! key].get -> string? +Microsoft.Testing.Framework.Configurations.TestFrameworkConfiguration +Microsoft.Testing.Framework.Configurations.TestFrameworkConfiguration.MaxParallelTests.get -> int +Microsoft.Testing.Framework.Configurations.TestFrameworkConfiguration.TestFrameworkConfiguration(int maxParallelTests = 2147483647) -> void +Microsoft.Testing.Framework.DynamicDataNameProvider +Microsoft.Testing.Framework.InternalUnsafeActionParameterizedTestNode +Microsoft.Testing.Framework.InternalUnsafeActionParameterizedTestNode.Body.get -> System.Action! +Microsoft.Testing.Framework.InternalUnsafeActionParameterizedTestNode.Body.init -> void +Microsoft.Testing.Framework.InternalUnsafeActionParameterizedTestNode.GetArguments.get -> System.Func!>! +Microsoft.Testing.Framework.InternalUnsafeActionParameterizedTestNode.GetArguments.init -> void +Microsoft.Testing.Framework.InternalUnsafeActionParameterizedTestNode.InternalUnsafeActionParameterizedTestNode() -> void +Microsoft.Testing.Framework.InternalUnsafeActionTaskParameterizedTestNode +Microsoft.Testing.Framework.InternalUnsafeActionTaskParameterizedTestNode.Body.get -> System.Action! +Microsoft.Testing.Framework.InternalUnsafeActionTaskParameterizedTestNode.Body.init -> void +Microsoft.Testing.Framework.InternalUnsafeActionTaskParameterizedTestNode.GetArguments.get -> System.Func!>!>! +Microsoft.Testing.Framework.InternalUnsafeActionTaskParameterizedTestNode.GetArguments.init -> void +Microsoft.Testing.Framework.InternalUnsafeActionTaskParameterizedTestNode.InternalUnsafeActionTaskParameterizedTestNode() -> void +Microsoft.Testing.Framework.InternalUnsafeActionTestNode +Microsoft.Testing.Framework.InternalUnsafeActionTestNode.Body.get -> System.Action! +Microsoft.Testing.Framework.InternalUnsafeActionTestNode.Body.init -> void +Microsoft.Testing.Framework.InternalUnsafeActionTestNode.InternalUnsafeActionTestNode() -> void +Microsoft.Testing.Framework.InternalUnsafeAsyncActionParameterizedTestNode +Microsoft.Testing.Framework.InternalUnsafeAsyncActionParameterizedTestNode.Body.get -> System.Func! +Microsoft.Testing.Framework.InternalUnsafeAsyncActionParameterizedTestNode.Body.init -> void +Microsoft.Testing.Framework.InternalUnsafeAsyncActionParameterizedTestNode.GetArguments.get -> System.Func!>! +Microsoft.Testing.Framework.InternalUnsafeAsyncActionParameterizedTestNode.GetArguments.init -> void +Microsoft.Testing.Framework.InternalUnsafeAsyncActionParameterizedTestNode.InternalUnsafeAsyncActionParameterizedTestNode() -> void +Microsoft.Testing.Framework.InternalUnsafeAsyncActionTaskParameterizedTestNode +Microsoft.Testing.Framework.InternalUnsafeAsyncActionTaskParameterizedTestNode.Body.get -> System.Func! +Microsoft.Testing.Framework.InternalUnsafeAsyncActionTaskParameterizedTestNode.Body.init -> void +Microsoft.Testing.Framework.InternalUnsafeAsyncActionTaskParameterizedTestNode.GetArguments.get -> System.Func!>!>! +Microsoft.Testing.Framework.InternalUnsafeAsyncActionTaskParameterizedTestNode.GetArguments.init -> void +Microsoft.Testing.Framework.InternalUnsafeAsyncActionTaskParameterizedTestNode.InternalUnsafeAsyncActionTaskParameterizedTestNode() -> void +Microsoft.Testing.Framework.InternalUnsafeAsyncActionTestNode +Microsoft.Testing.Framework.InternalUnsafeAsyncActionTestNode.Body.get -> System.Func! +Microsoft.Testing.Framework.InternalUnsafeAsyncActionTestNode.Body.init -> void +Microsoft.Testing.Framework.InternalUnsafeAsyncActionTestNode.InternalUnsafeAsyncActionTestNode() -> void +Microsoft.Testing.Framework.InternalUnsafeTestArgumentsEntry +Microsoft.Testing.Framework.InternalUnsafeTestArgumentsEntry.Arguments.get -> TArguments +Microsoft.Testing.Framework.InternalUnsafeTestArgumentsEntry.DisplayNameFragment.get -> string? +Microsoft.Testing.Framework.InternalUnsafeTestArgumentsEntry.InternalUnsafeTestArgumentsEntry(TArguments arguments, string! uidFragment, string? displayNameFragment = null) -> void +Microsoft.Testing.Framework.InternalUnsafeTestArgumentsEntry.UidFragment.get -> string! +Microsoft.Testing.Framework.ITestExecutionContext +Microsoft.Testing.Framework.ITestExecutionContext.AddTestAttachmentAsync(System.IO.FileInfo! file, string! displayName, string? description = null) -> System.Threading.Tasks.Task! +Microsoft.Testing.Framework.ITestExecutionContext.CancellationToken.get -> System.Threading.CancellationToken +Microsoft.Testing.Framework.ITestExecutionContext.CancelTestExecution() -> void +Microsoft.Testing.Framework.ITestExecutionContext.CancelTestExecution(int millisecondsDelay) -> void +Microsoft.Testing.Framework.ITestExecutionContext.CancelTestExecution(System.TimeSpan delay) -> void +Microsoft.Testing.Framework.ITestExecutionContext.Configuration.get -> Microsoft.Testing.Framework.Configurations.IConfiguration! +Microsoft.Testing.Framework.ITestExecutionContext.ReportException(System.Exception! exception, System.Threading.CancellationToken? timeoutCancellationToken = null) -> void +Microsoft.Testing.Framework.ITestExecutionContext.TestInfo.get -> Microsoft.Testing.Framework.ITestInfo! +Microsoft.Testing.Framework.ITestInfo +Microsoft.Testing.Framework.ITestInfo.DisplayName.get -> string! +Microsoft.Testing.Framework.ITestInfo.Properties.get -> Microsoft.Testing.Platform.Extensions.Messages.IProperty![]! +Microsoft.Testing.Framework.ITestInfo.StableUid.get -> Microsoft.Testing.Framework.TestNodeUid! +Microsoft.Testing.Framework.ITestNodesBuilder +Microsoft.Testing.Framework.ITestNodesBuilder.BuildAsync(Microsoft.Testing.Framework.ITestSessionContext! testSessionContext) -> System.Threading.Tasks.Task! +Microsoft.Testing.Framework.ITestSessionContext +Microsoft.Testing.Framework.ITestSessionContext.AddTestAttachmentAsync(System.IO.FileInfo! file, string! displayName, string? description = null) -> System.Threading.Tasks.Task! +Microsoft.Testing.Framework.ITestSessionContext.CancellationToken.get -> System.Threading.CancellationToken +Microsoft.Testing.Framework.ITestSessionContext.Configuration.get -> Microsoft.Testing.Framework.Configurations.IConfiguration! +Microsoft.Testing.Framework.TestApplicationBuilderExtensions +Microsoft.Testing.Framework.TestNode +Microsoft.Testing.Framework.TestNode.DisplayName.get -> string! +Microsoft.Testing.Framework.TestNode.DisplayName.init -> void +Microsoft.Testing.Framework.TestNode.OverriddenEdgeName.get -> string? +Microsoft.Testing.Framework.TestNode.OverriddenEdgeName.init -> void +Microsoft.Testing.Framework.TestNode.Properties.get -> Microsoft.Testing.Platform.Extensions.Messages.IProperty![]! +Microsoft.Testing.Framework.TestNode.Properties.init -> void +Microsoft.Testing.Framework.TestNode.StableUid.get -> Microsoft.Testing.Framework.TestNodeUid! +Microsoft.Testing.Framework.TestNode.StableUid.init -> void +Microsoft.Testing.Framework.TestNode.TestNode() -> void +Microsoft.Testing.Framework.TestNode.Tests.get -> Microsoft.Testing.Framework.TestNode![]! +Microsoft.Testing.Framework.TestNode.Tests.init -> void +Microsoft.Testing.Framework.TestNodeUid +Microsoft.Testing.Framework.TestNodeUid.$() -> Microsoft.Testing.Framework.TestNodeUid! +Microsoft.Testing.Framework.TestNodeUid.Deconstruct(out string! Value) -> void +Microsoft.Testing.Framework.TestNodeUid.Equals(Microsoft.Testing.Framework.TestNodeUid? other) -> bool +Microsoft.Testing.Framework.TestNodeUid.TestNodeUid(string! Value) -> void +Microsoft.Testing.Framework.TestNodeUid.Value.get -> string! +Microsoft.Testing.Framework.TestNodeUid.Value.init -> void +override Microsoft.Testing.Framework.TestNodeUid.Equals(object? obj) -> bool +override Microsoft.Testing.Framework.TestNodeUid.GetHashCode() -> int +override Microsoft.Testing.Framework.TestNodeUid.ToString() -> string! +static Microsoft.Testing.Framework.Configurations.ConfigurationExtensions.GetTestResultDirectory(this Microsoft.Testing.Framework.Configurations.IConfiguration! configuration) -> string! +static Microsoft.Testing.Framework.DynamicDataNameProvider.GetUidFragment(string![]! parameterNames, object?[]! data, int index) -> string! +static Microsoft.Testing.Framework.TestApplicationBuilderExtensions.AddTestFramework(this Microsoft.Testing.Platform.Builder.ITestApplicationBuilder! testApplicationBuilder, Microsoft.Testing.Framework.Configurations.TestFrameworkConfiguration? testFrameworkConfiguration = null, params Microsoft.Testing.Framework.ITestNodesBuilder![]! testNodesBuilder) -> void +static Microsoft.Testing.Framework.TestApplicationBuilderExtensions.AddTestFramework(this Microsoft.Testing.Platform.Builder.ITestApplicationBuilder! testApplicationBuilder, params Microsoft.Testing.Framework.ITestNodesBuilder![]! testNodesBuilder) -> void +static Microsoft.Testing.Framework.TestNodeUid.implicit operator Microsoft.Testing.Framework.TestNodeUid!(string! value) -> Microsoft.Testing.Framework.TestNodeUid! +static Microsoft.Testing.Framework.TestNodeUid.operator !=(Microsoft.Testing.Framework.TestNodeUid? left, Microsoft.Testing.Framework.TestNodeUid? right) -> bool +static Microsoft.Testing.Framework.TestNodeUid.operator ==(Microsoft.Testing.Framework.TestNodeUid? left, Microsoft.Testing.Framework.TestNodeUid? right) -> bool diff --git a/src/Adapter/MSTest.Engine/PublicAPI/net/PublicAPI.Shipped.txt b/src/Adapter/MSTest.Engine/PublicAPI/net/PublicAPI.Shipped.txt new file mode 100644 index 0000000000..7dc5c58110 --- /dev/null +++ b/src/Adapter/MSTest.Engine/PublicAPI/net/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/Adapter/MSTest.Engine/PublicAPI/net/PublicAPI.Unshipped.txt b/src/Adapter/MSTest.Engine/PublicAPI/net/PublicAPI.Unshipped.txt new file mode 100644 index 0000000000..7dc5c58110 --- /dev/null +++ b/src/Adapter/MSTest.Engine/PublicAPI/net/PublicAPI.Unshipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/Adapter/MSTest.Engine/PublicAPI/netstandard/PublicAPI.Shipped.txt b/src/Adapter/MSTest.Engine/PublicAPI/netstandard/PublicAPI.Shipped.txt new file mode 100644 index 0000000000..074c6ad103 --- /dev/null +++ b/src/Adapter/MSTest.Engine/PublicAPI/netstandard/PublicAPI.Shipped.txt @@ -0,0 +1,2 @@ +#nullable enable + diff --git a/src/Adapter/MSTest.Engine/PublicAPI/netstandard/PublicAPI.Unshipped.txt b/src/Adapter/MSTest.Engine/PublicAPI/netstandard/PublicAPI.Unshipped.txt new file mode 100644 index 0000000000..7dc5c58110 --- /dev/null +++ b/src/Adapter/MSTest.Engine/PublicAPI/netstandard/PublicAPI.Unshipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/Adapter/MSTest.Engine/RepositoryVersion.cs.template b/src/Adapter/MSTest.Engine/RepositoryVersion.cs.template new file mode 100644 index 0000000000..a874326017 --- /dev/null +++ b/src/Adapter/MSTest.Engine/RepositoryVersion.cs.template @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +/// +/// Repository version, created at build time. +/// +internal static class MSTestEngineRepositoryVersion +{ + public const string Version = "${Version}"; +} diff --git a/src/Adapter/MSTest.Engine/TestFramework/TestFramework.cs b/src/Adapter/MSTest.Engine/TestFramework/TestFramework.cs new file mode 100644 index 0000000000..963ffcfe65 --- /dev/null +++ b/src/Adapter/MSTest.Engine/TestFramework/TestFramework.cs @@ -0,0 +1,160 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +using Microsoft.Testing.Framework.Adapter; +using Microsoft.Testing.Framework.Configurations; +using Microsoft.Testing.Platform.Capabilities.TestFramework; +using Microsoft.Testing.Platform.Extensions.TestFramework; +using Microsoft.Testing.Platform.Helpers; +using Microsoft.Testing.Platform.Requests; +using Microsoft.Testing.Platform.TestHost; + +using IConfiguration = Microsoft.Testing.Platform.Configurations.IConfiguration; + +namespace Microsoft.Testing.Framework; + +internal sealed class TestFramework : IDisposable, ITestFramework +#if NETCOREAPP +#pragma warning disable SA1001 // Commas should be spaced correctly + , IAsyncDisposable +#pragma warning restore SA1001 // Commas should be spaced correctly +#endif +{ + private readonly TestingFrameworkExtension _extension; + private readonly CountdownEvent _incomingRequestCounter = new(1); + private readonly TestFrameworkEngine _engine; + private readonly List _sessionWarningMessages = new(); + private readonly List _sessionErrorMessages = new(); + private SessionUid? _sessionId; + + public TestFramework(TestFrameworkConfiguration testFrameworkConfiguration, ITestNodesBuilder[] testNodesBuilders, TestingFrameworkExtension extension, + IClock clock, ITask task, IConfiguration configuration, ITestFrameworkCapabilities capabilities) + { + _extension = extension; + _engine = new(testFrameworkConfiguration, testNodesBuilders, extension, capabilities, clock, task, configuration); + } + + /// + public string Uid => _extension.Uid; + + /// + public string Version => _extension.Version; + + /// + public string DisplayName => _extension.DisplayName; + + /// + public string Description => _extension.Description; + + /// + public async Task IsEnabledAsync() => await _extension.IsEnabledAsync(); + + public Task CreateTestSessionAsync(CreateTestSessionContext context) + { + if (_sessionId is not null) + { + throw new InvalidOperationException("Session already created"); + } + + _sessionId = context.SessionUid; + _sessionWarningMessages.Clear(); + _sessionErrorMessages.Clear(); + return Task.FromResult(new CreateTestSessionResult() { IsSuccess = true }); + } + + public async Task CloseTestSessionAsync(CloseTestSessionContext context) + { + _sessionId = null; + CloseTestSessionResult sessionResult = new(); + + try + { + // Ensure we have finished processing all requests. + _incomingRequestCounter.Signal(); + await _incomingRequestCounter.WaitAsync(context.CancellationToken); + + if (_sessionErrorMessages.Count > 0) + { + StringBuilder errorBuilder = new(); + errorBuilder.AppendLine("Test session failed with the following errors:"); + for (int i = 0; i < _sessionErrorMessages.Count; i++) + { + errorBuilder.Append(" - ").AppendLine(_sessionErrorMessages[i]); + } + + sessionResult.ErrorMessage = errorBuilder.ToString(); + } + + if (_sessionWarningMessages.Count > 0) + { + StringBuilder errorBuilder = new(); + errorBuilder.AppendLine("Test session raised the following warnings:"); + for (int i = 0; i < _sessionWarningMessages.Count; i++) + { + errorBuilder.Append(" - ").AppendLine(_sessionWarningMessages[i]); + } + + sessionResult.WarningMessage = errorBuilder.ToString(); + } + + sessionResult.IsSuccess = _incomingRequestCounter.CurrentCount == 0 && _sessionErrorMessages.Count == 0; + return sessionResult; + } + catch (OperationCanceledException ex) when (ex.CancellationToken == context.CancellationToken) + { + // We are being cancelled, so we don't need to wait anymore + sessionResult.WarningMessage += + (sessionResult.WarningMessage?.Length > 0 ? Environment.NewLine : string.Empty) + + "Closing the test session was cancelled."; + sessionResult.IsSuccess = false; + return sessionResult; + } + catch + { + throw; + } + } + + public async Task ExecuteRequestAsync(ExecuteRequestContext context) + { + _incomingRequestCounter.AddCount(); + try + { + if (context.Request is not TestExecutionRequest testExecutionRequest) + { + throw new InvalidOperationException($"Request type '{context.Request.GetType().FullName}' is not supported"); + } + + Result result = await _engine.ExecuteRequestAsync(testExecutionRequest, context.MessageBus, context.CancellationToken); + + foreach (IReason reason in result.Reasons) + { + if (reason is IErrorReason errorReason) + { + _sessionErrorMessages.Add(errorReason.Exception?.ToString() ?? errorReason.Message); + } + else if (reason is IWarningReason warningReason) + { + _sessionWarningMessages.Add(warningReason.Message); + } + } + } + finally + { + _incomingRequestCounter.Signal(); + context.Complete(); + } + } + + public void Dispose() => _incomingRequestCounter.Dispose(); + +#if NETCOREAPP + + public ValueTask DisposeAsync() + { + _incomingRequestCounter.Dispose(); + return default; + } + +#endif +} diff --git a/src/Adapter/MSTest.Engine/TestFramework/TestFrameworkCapabilities.cs b/src/Adapter/MSTest.Engine/TestFramework/TestFrameworkCapabilities.cs new file mode 100644 index 0000000000..4d2e6afba3 --- /dev/null +++ b/src/Adapter/MSTest.Engine/TestFramework/TestFrameworkCapabilities.cs @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +using Microsoft.Testing.Extensions.TrxReport.Abstractions; +using Microsoft.Testing.Platform.Capabilities.TestFramework; + +namespace Microsoft.Testing.Framework; + +internal sealed class TestFrameworkCapabilities : ITestFrameworkCapabilities +{ + private readonly ITestNodesBuilder[] _testNodesBuilders; + + public TestFrameworkCapabilities(ITestNodesBuilder[] testNodesBuilders) + => _testNodesBuilders = testNodesBuilders; + + public IReadOnlyCollection Capabilities + => new[] { new TestFrameworkCapabilitiesSet(_testNodesBuilders) }; +} + +internal sealed class TestFrameworkCapabilitiesSet : + ITestNodesTreeFilterTestFrameworkCapability, + ITrxReportCapability, + INamedFeatureCapability +{ + private const string MultiRequestSupport = "experimental_multiRequestSupport"; + private readonly ITestNodesBuilder[] _testNodesBuilders; + + public TestFrameworkCapabilitiesSet(ITestNodesBuilder[] testNodesBuilders) + { + IsTrxReportCapabilitySupported = testNodesBuilders.All(x => x.HasCapability()); + _testNodesBuilders = testNodesBuilders; + } + + public bool IsTrxReportEnabled { get; private set; } + + public bool IsTrxReportCapabilitySupported { get; } + + bool ITestNodesTreeFilterTestFrameworkCapability.IsSupported { get; } = true; + + bool ITrxReportCapability.IsSupported => IsTrxReportCapabilitySupported; + + public void Enable() + { + IsTrxReportEnabled = true; + foreach (ITestNodesBuilder item in _testNodesBuilders) + { + item.GetCapability()?.Enable(); + } + } + + bool INamedFeatureCapability.IsSupported(string featureName) => featureName == MultiRequestSupport; +} diff --git a/src/Adapter/MSTest.Engine/TestFramework/TestingFrameworkExtension.cs b/src/Adapter/MSTest.Engine/TestFramework/TestingFrameworkExtension.cs new file mode 100644 index 0000000000..07fc54b2f2 --- /dev/null +++ b/src/Adapter/MSTest.Engine/TestFramework/TestingFrameworkExtension.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +using Microsoft.Testing.Platform.Extensions; + +namespace Microsoft.Testing.Framework.Adapter; + +internal sealed class TestingFrameworkExtension : IExtension +{ + public string Uid => "MSTestEngine"; + + public string Version => TestFrameworkConstants.DefaultSemVer; + + public string DisplayName => "MSTest AOT"; + + public string Description => "MSTest AOT. This framework allows you to test your code anywhere in any mode (all OSes, all platforms, all configurations...)."; + + public Task IsEnabledAsync() => Task.FromResult(true); +} diff --git a/src/Adapter/MSTest.Engine/TestNodes/FactoryTestNodesBuilder.cs b/src/Adapter/MSTest.Engine/TestNodes/FactoryTestNodesBuilder.cs new file mode 100644 index 0000000000..8f2b9bc3b6 --- /dev/null +++ b/src/Adapter/MSTest.Engine/TestNodes/FactoryTestNodesBuilder.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +using Microsoft.Testing.Platform.Capabilities; +using Microsoft.Testing.Platform.Capabilities.TestFramework; + +namespace Microsoft.Testing.Framework; + +internal sealed class FactoryTestNodesBuilder : ITestNodesBuilder +{ + private readonly Func _testNodesFactory; + + public FactoryTestNodesBuilder(Func testNodesFactory) + => _testNodesFactory = testNodesFactory; + + public bool IsSupportingTrxProperties { get; } + + IReadOnlyCollection ICapabilities.Capabilities + => Array.Empty(); + + public Task BuildAsync(ITestSessionContext _) => Task.FromResult(_testNodesFactory()); +} diff --git a/src/Adapter/MSTest.Engine/TestNodes/FrameworkTestNodeProperties.cs b/src/Adapter/MSTest.Engine/TestNodes/FrameworkTestNodeProperties.cs new file mode 100644 index 0000000000..718e6b8b40 --- /dev/null +++ b/src/Adapter/MSTest.Engine/TestNodes/FrameworkTestNodeProperties.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +using Microsoft.Testing.Platform.Extensions.Messages; + +namespace Microsoft.Testing.Framework; + +internal readonly struct FrameworkEngineMetadataProperty() : IProperty +{ + public bool PreventArgumentsExpansion { get; init; } + + public string[] UsedFixtureIds { get; init; } = Array.Empty(); +} diff --git a/src/Adapter/MSTest.Engine/TestNodes/IActionTestNode.cs b/src/Adapter/MSTest.Engine/TestNodes/IActionTestNode.cs new file mode 100644 index 0000000000..25624bfa12 --- /dev/null +++ b/src/Adapter/MSTest.Engine/TestNodes/IActionTestNode.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +namespace Microsoft.Testing.Framework; + +internal interface IActionTestNode : IActionableTestNode +{ + void Invoke(ITestExecutionContext testExecutionContext); +} diff --git a/src/Adapter/MSTest.Engine/TestNodes/IActionableTestNode.cs b/src/Adapter/MSTest.Engine/TestNodes/IActionableTestNode.cs new file mode 100644 index 0000000000..158cd96a6e --- /dev/null +++ b/src/Adapter/MSTest.Engine/TestNodes/IActionableTestNode.cs @@ -0,0 +1,8 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +namespace Microsoft.Testing.Framework; + +internal interface IActionableTestNode +{ +} diff --git a/src/Adapter/MSTest.Engine/TestNodes/IAsyncActionTestNode.cs b/src/Adapter/MSTest.Engine/TestNodes/IAsyncActionTestNode.cs new file mode 100644 index 0000000000..0cfec0e29e --- /dev/null +++ b/src/Adapter/MSTest.Engine/TestNodes/IAsyncActionTestNode.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +namespace Microsoft.Testing.Framework; + +internal interface IAsyncActionTestNode : IActionableTestNode +{ + Task InvokeAsync(ITestExecutionContext testExecutionContext); +} diff --git a/src/Adapter/MSTest.Engine/TestNodes/IAsyncParameterizedTestNode.cs b/src/Adapter/MSTest.Engine/TestNodes/IAsyncParameterizedTestNode.cs new file mode 100644 index 0000000000..06f76ad20a --- /dev/null +++ b/src/Adapter/MSTest.Engine/TestNodes/IAsyncParameterizedTestNode.cs @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +#if NET +namespace Microsoft.Testing.Framework; + +internal interface IAsyncParameterizedTestNode : IExpandableTestNode +{ + Func> GetArguments { get; } +} +#endif diff --git a/src/Adapter/MSTest.Engine/TestNodes/IContextTestNode.cs b/src/Adapter/MSTest.Engine/TestNodes/IContextTestNode.cs new file mode 100644 index 0000000000..1cef2c26a8 --- /dev/null +++ b/src/Adapter/MSTest.Engine/TestNodes/IContextTestNode.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +namespace Microsoft.Testing.Framework; + +internal interface IContextTestNode +{ + TContext Context { get; } +} diff --git a/src/Adapter/MSTest.Engine/TestNodes/IExpandableTestNode.cs b/src/Adapter/MSTest.Engine/TestNodes/IExpandableTestNode.cs new file mode 100644 index 0000000000..ddcbe0e38a --- /dev/null +++ b/src/Adapter/MSTest.Engine/TestNodes/IExpandableTestNode.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +namespace Microsoft.Testing.Framework; + +internal interface IExpandableTestNode +{ + TestNode GetExpandedTestNode(object arguments, string argumentFragmentUid, string argumentFragmentDisplayName); +} diff --git a/src/Adapter/MSTest.Engine/TestNodes/IParameterizedAsyncActionTestNode.cs b/src/Adapter/MSTest.Engine/TestNodes/IParameterizedAsyncActionTestNode.cs new file mode 100644 index 0000000000..8c40e0cc10 --- /dev/null +++ b/src/Adapter/MSTest.Engine/TestNodes/IParameterizedAsyncActionTestNode.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +namespace Microsoft.Testing.Framework; + +internal interface IParameterizedAsyncActionTestNode : IActionableTestNode +{ + Task InvokeAsync(ITestExecutionContext testExecutionContext, Func, Task> safeInvoke); +} diff --git a/src/Adapter/MSTest.Engine/TestNodes/IParameterizedTestNode.cs b/src/Adapter/MSTest.Engine/TestNodes/IParameterizedTestNode.cs new file mode 100644 index 0000000000..4e0397809c --- /dev/null +++ b/src/Adapter/MSTest.Engine/TestNodes/IParameterizedTestNode.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +namespace Microsoft.Testing.Framework; + +internal interface IParameterizedTestNode : IExpandableTestNode +{ + Func GetArguments { get; } +} diff --git a/src/Adapter/MSTest.Engine/TestNodes/ITaskParameterizedTestNode.cs b/src/Adapter/MSTest.Engine/TestNodes/ITaskParameterizedTestNode.cs new file mode 100644 index 0000000000..9734cc2081 --- /dev/null +++ b/src/Adapter/MSTest.Engine/TestNodes/ITaskParameterizedTestNode.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +namespace Microsoft.Testing.Framework; + +internal interface ITaskParameterizedTestNode : IExpandableTestNode +{ + Func> GetArguments { get; } +} diff --git a/src/Adapter/MSTest.Engine/TestNodes/ITestNodeBuilder.cs b/src/Adapter/MSTest.Engine/TestNodes/ITestNodeBuilder.cs new file mode 100644 index 0000000000..17a81de037 --- /dev/null +++ b/src/Adapter/MSTest.Engine/TestNodes/ITestNodeBuilder.cs @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +using Microsoft.Testing.Platform.Capabilities.TestFramework; + +namespace Microsoft.Testing.Framework; + +public interface ITestNodesBuilder : ITestFrameworkCapabilities +{ + Task BuildAsync(ITestSessionContext testSessionContext); +} diff --git a/src/Adapter/MSTest.Engine/TestNodes/InternalUnsafeActionParameterizedTestNode.cs b/src/Adapter/MSTest.Engine/TestNodes/InternalUnsafeActionParameterizedTestNode.cs new file mode 100644 index 0000000000..749b65b3a0 --- /dev/null +++ b/src/Adapter/MSTest.Engine/TestNodes/InternalUnsafeActionParameterizedTestNode.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +using Microsoft.Testing.Framework.Helpers; + +namespace Microsoft.Testing.Framework; + +/// +/// WARNING: This type is public, but is meant for use only by MSTest source generator. Unannounced breaking changes to this API may happen. +/// +/// Type that holds the parameter data. +public sealed class InternalUnsafeActionParameterizedTestNode + : TestNode, IParameterizedTestNode, IParameterizedAsyncActionTestNode +{ + public required Action Body { get; init; } + + public required Func> GetArguments { get; init; } + + Func IParameterizedTestNode.GetArguments => GetArguments; + + async Task IParameterizedAsyncActionTestNode.InvokeAsync(ITestExecutionContext testExecutionContext, Func, Task> safeInvoke) + { + foreach (TData item in GetArguments()) + { + await safeInvoke(() => + { + Body(testExecutionContext, item); + return Task.CompletedTask; + }); + } + } + + TestNode IExpandableTestNode.GetExpandedTestNode(object arguments, string argumentFragmentUid, string argumentFragmentDisplayName) + => new InternalUnsafeActionTestNode + { + StableUid = TestNodeExpansionHelper.GenerateStableUid(StableUid, argumentFragmentUid), + DisplayName = TestNodeExpansionHelper.GenerateDisplayName(DisplayName, argumentFragmentDisplayName), + Body = testExecutionContext => Body(testExecutionContext, (TData)arguments), + Properties = Properties, + }; +} diff --git a/src/Adapter/MSTest.Engine/TestNodes/InternalUnsafeActionTaskParameterizedTestNode.cs b/src/Adapter/MSTest.Engine/TestNodes/InternalUnsafeActionTaskParameterizedTestNode.cs new file mode 100644 index 0000000000..28dffaa306 --- /dev/null +++ b/src/Adapter/MSTest.Engine/TestNodes/InternalUnsafeActionTaskParameterizedTestNode.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +using Microsoft.Testing.Framework.Helpers; + +namespace Microsoft.Testing.Framework; + +/// +/// WARNING: This type is public, but is meant for use only by MSTest source generator. Unannounced breaking changes to this API may happen. +/// +/// Type that holds the parameter data. +public sealed class InternalUnsafeActionTaskParameterizedTestNode + : TestNode, ITaskParameterizedTestNode, IParameterizedAsyncActionTestNode +{ + public required Action Body { get; init; } + + public required Func>> GetArguments { get; init; } + + Func> ITaskParameterizedTestNode.GetArguments => async () => await GetArguments(); + + async Task IParameterizedAsyncActionTestNode.InvokeAsync(ITestExecutionContext testExecutionContext, Func, Task> safeInvoke) + { + foreach (TData item in await GetArguments()) + { + await safeInvoke(() => + { + Body(testExecutionContext, item); + return Task.CompletedTask; + }); + } + } + + TestNode IExpandableTestNode.GetExpandedTestNode(object arguments, string argumentFragmentUid, string argumentFragmentDisplayName) + => new InternalUnsafeActionTestNode + { + StableUid = TestNodeExpansionHelper.GenerateStableUid(StableUid, argumentFragmentUid), + DisplayName = TestNodeExpansionHelper.GenerateDisplayName(DisplayName, argumentFragmentDisplayName), + Body = testExecutionContext => Body(testExecutionContext, (TData)arguments), + Properties = Properties, + }; +} diff --git a/src/Adapter/MSTest.Engine/TestNodes/InternalUnsafeActionTestNode.cs b/src/Adapter/MSTest.Engine/TestNodes/InternalUnsafeActionTestNode.cs new file mode 100644 index 0000000000..ff06966fd0 --- /dev/null +++ b/src/Adapter/MSTest.Engine/TestNodes/InternalUnsafeActionTestNode.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +namespace Microsoft.Testing.Framework; + +/// +/// WARNING: This type is public, but is meant for use only by MSTest source generator. Unannounced breaking changes to this API may happen. +/// +public sealed class InternalUnsafeActionTestNode : TestNode, IActionTestNode +{ + public required Action Body { get; init; } + + void IActionTestNode.Invoke(ITestExecutionContext testExecutionContext) + => Body(testExecutionContext); +} diff --git a/src/Adapter/MSTest.Engine/TestNodes/InternalUnsafeAsyncActionParameterizedTestNode.cs b/src/Adapter/MSTest.Engine/TestNodes/InternalUnsafeAsyncActionParameterizedTestNode.cs new file mode 100644 index 0000000000..3dbc8ace32 --- /dev/null +++ b/src/Adapter/MSTest.Engine/TestNodes/InternalUnsafeAsyncActionParameterizedTestNode.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +using Microsoft.Testing.Framework.Helpers; + +namespace Microsoft.Testing.Framework; + +/// +/// WARNING: This type is public, but is meant for use only by MSTest source generator. Unannounced breaking changes to this API may happen. +/// +/// Type that holds the parameter data. +public sealed class InternalUnsafeAsyncActionParameterizedTestNode + : TestNode, IParameterizedTestNode, IParameterizedAsyncActionTestNode +{ + public required Func Body { get; init; } + + public required Func> GetArguments { get; init; } + + Func IParameterizedTestNode.GetArguments => GetArguments; + + async Task IParameterizedAsyncActionTestNode.InvokeAsync(ITestExecutionContext testExecutionContext, Func, Task> safeInvoke) + { + foreach (TData item in GetArguments()) + { + await safeInvoke(async () => await Body(testExecutionContext, item)); + } + } + + TestNode IExpandableTestNode.GetExpandedTestNode(object arguments, string argumentFragmentUid, string argumentFragmentDisplayName) + => new InternalUnsafeAsyncActionTestNode + { + StableUid = TestNodeExpansionHelper.GenerateStableUid(StableUid, argumentFragmentUid), + DisplayName = TestNodeExpansionHelper.GenerateDisplayName(DisplayName, argumentFragmentDisplayName), + Body = node => Body(node, (TData)arguments), + Properties = Properties, + }; +} diff --git a/src/Adapter/MSTest.Engine/TestNodes/InternalUnsafeAsyncActionTaskParameterizedTestNode.cs b/src/Adapter/MSTest.Engine/TestNodes/InternalUnsafeAsyncActionTaskParameterizedTestNode.cs new file mode 100644 index 0000000000..f27470ba19 --- /dev/null +++ b/src/Adapter/MSTest.Engine/TestNodes/InternalUnsafeAsyncActionTaskParameterizedTestNode.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +using Microsoft.Testing.Framework.Helpers; + +namespace Microsoft.Testing.Framework; + +/// +/// WARNING: This type is public, but is meant for use only by MSTest source generator. Unannounced breaking changes to this API may happen. +/// +/// Type that holds the parameter data. +public sealed class InternalUnsafeAsyncActionTaskParameterizedTestNode + : TestNode, ITaskParameterizedTestNode, IParameterizedAsyncActionTestNode +{ + public required Func Body { get; init; } + + public required Func>> GetArguments { get; init; } + + Func> ITaskParameterizedTestNode.GetArguments => async () => await GetArguments(); + + async Task IParameterizedAsyncActionTestNode.InvokeAsync(ITestExecutionContext testExecutionContext, Func, Task> safeInvoke) + { + foreach (TData item in await GetArguments()) + { + await safeInvoke(async () => await Body(testExecutionContext, item)); + } + } + + TestNode IExpandableTestNode.GetExpandedTestNode(object arguments, string argumentFragmentUid, string argumentFragmentDisplayName) + => new InternalUnsafeAsyncActionTestNode + { + StableUid = TestNodeExpansionHelper.GenerateStableUid(StableUid, argumentFragmentUid), + DisplayName = TestNodeExpansionHelper.GenerateDisplayName(DisplayName, argumentFragmentDisplayName), + Body = node => Body(node, (TData)arguments), + Properties = Properties, + }; +} diff --git a/src/Adapter/MSTest.Engine/TestNodes/InternalUnsafeAsyncActionTestNode.cs b/src/Adapter/MSTest.Engine/TestNodes/InternalUnsafeAsyncActionTestNode.cs new file mode 100644 index 0000000000..5d8a0aa48a --- /dev/null +++ b/src/Adapter/MSTest.Engine/TestNodes/InternalUnsafeAsyncActionTestNode.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +namespace Microsoft.Testing.Framework; + +/// +/// WARNING: This type is public, but is meant for use only by MSTest source generator. Unannounced breaking changes to this API may happen. +/// +public sealed class InternalUnsafeAsyncActionTestNode : TestNode, IAsyncActionTestNode +{ + public required Func Body { get; init; } + + async Task IAsyncActionTestNode.InvokeAsync(ITestExecutionContext testExecutionContext) + => await Body(testExecutionContext); +} diff --git a/src/Adapter/MSTest.Engine/TestNodes/TestNode.cs b/src/Adapter/MSTest.Engine/TestNodes/TestNode.cs new file mode 100644 index 0000000000..58f015c055 --- /dev/null +++ b/src/Adapter/MSTest.Engine/TestNodes/TestNode.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +using Microsoft.Testing.Platform.Extensions.Messages; + +namespace Microsoft.Testing.Framework; + +[DebuggerDisplay("StableUid = {StableUid.Value}, TestsCount = {Tests.Length}, PropertiesCount = {Properties.Length}")] +public class TestNode +{ + public required TestNodeUid StableUid { get; init; } + + public required string DisplayName { get; init; } + + /// + /// Gets the name of the edge that connects this node to its parent. + /// + public string? OverriddenEdgeName { get; init; } + + public IProperty[] Properties { get; init; } = Array.Empty(); + + public TestNode[] Tests { get; init; } = Array.Empty(); +} diff --git a/src/Adapter/MSTest.Engine/TestNodes/TestNodeUid.cs b/src/Adapter/MSTest.Engine/TestNodes/TestNodeUid.cs new file mode 100644 index 0000000000..c1da416bb5 --- /dev/null +++ b/src/Adapter/MSTest.Engine/TestNodes/TestNodeUid.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +namespace Microsoft.Testing.Framework; + +/// +/// Represents a unique identifier for a test node. +/// +/// The unique identifier. +[DebuggerDisplay("{Value}")] +public sealed record TestNodeUid(string Value) +{ + /// + /// Implicitly converts a string to a . + /// + /// The unique identifier. + public static implicit operator TestNodeUid(string value) + => new(value); +} diff --git a/src/Adapter/MSTest.Engine/TimeSheet.cs b/src/Adapter/MSTest.Engine/TimeSheet.cs new file mode 100644 index 0000000000..db5fb84217 --- /dev/null +++ b/src/Adapter/MSTest.Engine/TimeSheet.cs @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +using Microsoft.Testing.Platform.Helpers; + +namespace Microsoft.Testing.Framework; + +/// +/// Keeps track of time and duration of a test. +/// +internal sealed class TimeSheet +{ + private readonly Stopwatch _stopwatch; + private readonly IClock _clock; + + /// + /// Initializes a new instance of the class. + /// Creates new instance of this class, starts measuring time in queue. + /// + public TimeSheet(IClock clock) + { + _stopwatch = Stopwatch.StartNew(); + _clock = clock; + } + + /// + /// Gets when the test started in UTC. Not-precise, because we just capture the current DateTimeUtc. + /// + public DateTimeOffset StartTime { get; private set; } + + /// + /// Gets when the test stopped in UTC. Not-precise, because we just capture the current DateTimeUtc. + /// + public DateTimeOffset StopTime { get; private set; } + + /// + /// Gets how long we've spent in queue before being executed. Precise, measured by Stopwatch. + /// + public TimeSpan DurationInQueue { get; private set; } + + /// + /// Gets how long we've spent executing the test. Precise, measured by Stopwatch. + /// + public TimeSpan Duration { get; private set; } + + /// + /// Record the start of the test, this will capture time spent in queue and start measuring duration of test. + /// + internal void RecordStart() + { + StartTime = _clock.UtcNow; + DurationInQueue = _stopwatch.Elapsed; + _stopwatch.Restart(); + } + + /// + /// Record the end of the test, this will capture time spent executing the test. + /// + internal void RecordStop() + { + StopTime = _clock.UtcNow; + Duration = _stopwatch.Elapsed; + } +} diff --git a/src/Adapter/MSTest.Engine/build/MSTest.Engine.props b/src/Adapter/MSTest.Engine/build/MSTest.Engine.props new file mode 100644 index 0000000000..68ae1bd96f --- /dev/null +++ b/src/Adapter/MSTest.Engine/build/MSTest.Engine.props @@ -0,0 +1,3 @@ + + + diff --git a/src/Adapter/MSTest.Engine/build/MSTest.Engine.targets b/src/Adapter/MSTest.Engine/build/MSTest.Engine.targets new file mode 100644 index 0000000000..f7451dd95f --- /dev/null +++ b/src/Adapter/MSTest.Engine/build/MSTest.Engine.targets @@ -0,0 +1,3 @@ + + + diff --git a/src/Adapter/MSTest.Engine/buildMultiTargeting/MSTest.Engine.props b/src/Adapter/MSTest.Engine/buildMultiTargeting/MSTest.Engine.props new file mode 100644 index 0000000000..ac5c535fa4 --- /dev/null +++ b/src/Adapter/MSTest.Engine/buildMultiTargeting/MSTest.Engine.props @@ -0,0 +1,13 @@ + + + + + + MSTest.SourceGeneration + Microsoft.Testing.Framework.SourceGeneration.SourceGeneratedTestingPlatformBuilderHook + + + diff --git a/src/Adapter/MSTest.Engine/buildMultiTargeting/MSTest.Engine.targets b/src/Adapter/MSTest.Engine/buildMultiTargeting/MSTest.Engine.targets new file mode 100644 index 0000000000..cf33b5d45c --- /dev/null +++ b/src/Adapter/MSTest.Engine/buildMultiTargeting/MSTest.Engine.targets @@ -0,0 +1,3 @@ + + + diff --git a/src/Analyzers/MSTest.SourceGeneration/.editorconfig b/src/Analyzers/MSTest.SourceGeneration/.editorconfig new file mode 100644 index 0000000000..7d7bac49f4 --- /dev/null +++ b/src/Analyzers/MSTest.SourceGeneration/.editorconfig @@ -0,0 +1,2 @@ +[*.{cs,vb}] +file_header_template = Copyright (c) Microsoft Corporation. All rights reserved.\nLicensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. diff --git a/src/Analyzers/MSTest.SourceGeneration/BannedSymbols.txt b/src/Analyzers/MSTest.SourceGeneration/BannedSymbols.txt new file mode 100644 index 0000000000..b46f825771 --- /dev/null +++ b/src/Analyzers/MSTest.SourceGeneration/BannedSymbols.txt @@ -0,0 +1,2 @@ +M:System.Text.StringBuilder.AppendLine(); Calls to 'Environment.NewLine' should be avoided in source generators +M:System.Text.StringBuilder.AppendLine(System.String); Calls to 'Environment.NewLine' should be avoided in source generators diff --git a/src/Analyzers/MSTest.SourceGeneration/Generators/TestNodesGenerator.cs b/src/Analyzers/MSTest.SourceGeneration/Generators/TestNodesGenerator.cs new file mode 100644 index 0000000000..c841f5d90d --- /dev/null +++ b/src/Analyzers/MSTest.SourceGeneration/Generators/TestNodesGenerator.cs @@ -0,0 +1,245 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +using System.Collections.Immutable; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.Testing.Framework.SourceGeneration.Helpers; +using Microsoft.Testing.Framework.SourceGeneration.ObjectModels; + +namespace Microsoft.Testing.Framework.SourceGeneration; + +[Generator] +internal sealed class TestNodesGenerator : IIncrementalGenerator +{ + public void Initialize(IncrementalGeneratorInitializationContext context) + { + IncrementalValuesProvider testClassesProvider = context.SyntaxProvider.ForAttributeWithMetadataName( + "Microsoft.VisualStudio.TestTools.UnitTesting.TestClassAttribute", + static (node, _) => + node is TypeDeclarationSyntax typeDeclarationSyntax + && (typeDeclarationSyntax.Modifiers.Any(SyntaxKind.PublicKeyword) || typeDeclarationSyntax.Modifiers.Any(SyntaxKind.InternalKeyword)) + // No static classes. + && !typeDeclarationSyntax.Modifiers.Any(SyntaxKind.StaticKeyword), + static (context, _) => + { + WellKnownTypes wellKnownTypes = new(context.SemanticModel.Compilation); + var testClassInfo = TestTypeInfo.TryBuild(context, wellKnownTypes); + return testClassInfo; + }) + .WhereNotNull(); + + // Generate a file with one static class and one static TestNode field for all public classes we find + context.RegisterImplementationSourceOutput(testClassesProvider, AddTestClassNode); + + IncrementalValueProvider<(string? Left, ImmutableArray Right)> assemblyNamespacesProvider + = context.CompilationProvider.Select((compilation, _) => compilation.AssemblyName) + .Combine(testClassesProvider.Collect()); + + context.RegisterImplementationSourceOutput(assemblyNamespacesProvider, AddAssemblyTestNode); + } + + private static void AddAssemblyTestNode(SourceProductionContext context, (string? AssemblyName, ImmutableArray TestClasses) provider) + { + string assemblyName = provider.AssemblyName ?? ""; + ImmutableArray testClasses = provider.TestClasses; + + var sourceStringBuilder = new IndentedStringBuilder(); + sourceStringBuilder.AppendAutoGeneratedHeader(); + sourceStringBuilder.AppendLine(); + + TestNamespaceInfo[] uniqueUsedNamespaces = testClasses + .Select(x => x.ContainingNamespace) + .Distinct() + .ToArray(); + + string? safeAssemblyName = null; + IDisposable? namespaceBlock = null; + try + { + if (!uniqueUsedNamespaces.Any(x => x.IsGlobalNamespace)) + { + safeAssemblyName = ToSafeNamespace(assemblyName); + + // TODO: We should look for the default namespace, if made visible to the compiler, or default to assembly name. + namespaceBlock = sourceStringBuilder.AppendBlock($"namespace {safeAssemblyName}"); + } + + foreach (TestNamespaceInfo usedNamespace in uniqueUsedNamespaces) + { + if (!usedNamespace.IsGlobalNamespace) + { + sourceStringBuilder.AppendLine($"using {usedNamespace.FullyQualifiedName};"); + } + } + + sourceStringBuilder.AppendLine("using ColGen = global::System.Collections.Generic;"); + sourceStringBuilder.AppendLine("using CA = global::System.Diagnostics.CodeAnalysis;"); + sourceStringBuilder.AppendLine("using Sys = global::System;"); + sourceStringBuilder.AppendLine("using Tasks = global::System.Threading.Tasks;"); + sourceStringBuilder.AppendLine("using Msg = global::Microsoft.Testing.Platform.Extensions.Messages;"); + sourceStringBuilder.AppendLine("using MSTF = global::Microsoft.Testing.Framework;"); + sourceStringBuilder.AppendLine("using Cap = global::Microsoft.Testing.Platform.Capabilities.TestFramework;"); + sourceStringBuilder.AppendLine("using TrxReport = global::Microsoft.Testing.Extensions.TrxReport.Abstractions;"); + sourceStringBuilder.AppendLine(); + + sourceStringBuilder.AppendLine("[CA::ExcludeFromCodeCoverage]"); + using (sourceStringBuilder.AppendBlock("public sealed class SourceGeneratedTestNodesBuilder : MSTF::ITestNodesBuilder")) + { + using (sourceStringBuilder.AppendBlock("private sealed class ClassCapabilities : TrxReport::ITrxReportCapability")) + { + string isTrxReportSupported = testClasses.IsEmpty ? "false" : "true"; + sourceStringBuilder.AppendLine($"bool TrxReport::ITrxReportCapability.IsSupported {{ get; }} = {isTrxReportSupported};"); + sourceStringBuilder.AppendLine("void TrxReport::ITrxReportCapability.Enable() {}"); + } + + sourceStringBuilder.AppendLine(); + sourceStringBuilder.AppendLine("public ColGen::IReadOnlyCollection Capabilities { get; } = new Cap::ITestFrameworkCapability[1] { new ClassCapabilities() };"); + sourceStringBuilder.AppendLine(); + + using (sourceStringBuilder.AppendBlock($"public Tasks::Task BuildAsync(MSTF::ITestSessionContext testSessionContext)")) + { + if (testClasses.IsEmpty) + { + sourceStringBuilder.AppendLine("return Tasks::Task.FromResult(Sys::Array.Empty());"); + } + else + { + AppendAssemblyTestNodeBuilderContent(sourceStringBuilder, assemblyName, testClasses); + } + } + } + } + finally + { + namespaceBlock?.Dispose(); + } + + string code = sourceStringBuilder.ToString(); + // DEBUG: Debug.WriteLine is useful to observe the code when changing the source code generator or applying it to a new test suite. + // VS is caching the generator, so start DebugView++ and just rebuild the TestContainer to make changes, + // and observe the compiler process only (csc.exe). + // Debug.WriteLine(code); + context.AddSource("SourceGeneratedTestNodesBuilder.g.cs", code); + + IndentedStringBuilder hookCode = new(); + hookCode.AppendAutoGeneratedHeader(); + using (hookCode.AppendBlock("namespace Microsoft.Testing.Framework.SourceGeneration")) + { + hookCode.AppendLine("[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]"); + using (hookCode.AppendBlock("public static class SourceGeneratedTestingPlatformBuilderHook")) + { + using (hookCode.AppendBlock("public static void AddExtensions(Microsoft.Testing.Platform.Builder.ITestApplicationBuilder testApplicationBuilder, string[] _)")) + { + hookCode.AppendLine("testApplicationBuilder.AddTestFramework(new Microsoft.Testing.Framework.Configurations.TestFrameworkConfiguration(System.Environment.ProcessorCount),"); + hookCode.IndentationLevel++; + hookCode.AppendLine($"new {(safeAssemblyName is not null ? safeAssemblyName + "." : string.Empty)}SourceGeneratedTestNodesBuilder());"); + hookCode.IndentationLevel--; + } + } + } + + // Add a hook to the test platform builder to register the test framework to MSBuild. + context.AddSource("SourceGeneratedTestingPlatformBuilderHook.g.cs", hookCode.ToString()); + } + + private static void AppendAssemblyTestNodeBuilderContent(IndentedStringBuilder sourceStringBuilder, string assemblyName, + ImmutableArray testClasses) + { + Dictionary rootVariablesPerNamespace = new(); + int variableIndex = 1; + IEnumerable> classesPerNamespaces = testClasses.GroupBy(x => x.ContainingNamespace); + foreach (IGrouping namespaceClasses in classesPerNamespaces) + { + string namespaceTestsVariableName = $"namespace{variableIndex}Tests"; + rootVariablesPerNamespace.Add(namespaceClasses.Key, namespaceTestsVariableName); + sourceStringBuilder.AppendLine($"ColGen::List {namespaceTestsVariableName} = new();"); + + foreach (TestTypeInfo testClassInfo in namespaceClasses) + { + string escapedClassFullName = TestNodeHelpers.GenerateEscapedName(testClassInfo.FullyQualifiedName); + sourceStringBuilder.AppendLine($"{namespaceTestsVariableName}.Add({testClassInfo.GeneratedTypeName}.TestNode);"); + } + + variableIndex++; + sourceStringBuilder.AppendLine(); + } + + sourceStringBuilder.Append("MSTF::TestNode root = "); + + using (sourceStringBuilder.AppendTestNode(assemblyName, assemblyName, Array.Empty(), ';')) + { + foreach (IGrouping group in classesPerNamespaces) + { + group.Key.AppendNamespaceTestNode(sourceStringBuilder, rootVariablesPerNamespace[group.Key]); + } + } + + sourceStringBuilder.AppendLine(); + sourceStringBuilder.AppendLine("return Tasks::Task.FromResult(new MSTF::TestNode[1] { root });"); + } + + private static void AddTestClassNode(SourceProductionContext context, TestTypeInfo testClassInfo) + { + var sourceStringBuilder = new IndentedStringBuilder(); + sourceStringBuilder.AppendAutoGeneratedHeader(); + sourceStringBuilder.AppendLine(); + + testClassInfo.AppendTestNode(sourceStringBuilder); + + string code = sourceStringBuilder.ToString(); + // DEBUG: Debug.WriteLine is useful to observe the code when changing the source code generator or applying it to a new test suite. + // VS is caching the generator, so start DebugView++ and just rebuild the TestContainer to make changes, + // and observe the compiler process only (csc.exe). + // Debug.WriteLine(code); + context.AddSource($"{testClassInfo.FullyQualifiedName}.g.cs", code); + } + + // Borrowed from https://github.com/dotnet/templating/blob/dad34814012bf29aa35eaf8e8013af4b10b997da/src/Microsoft.TemplateEngine.Orchestrator.RunnableProjects/ValueForms/DefaultSafeNamespaceValueFormFactory.cs#L10 + internal /* for testing purpose */ static string ToSafeNamespace(string value) + { + const char invalidCharacterReplacement = '_'; + + value = value ?? throw new ArgumentNullException(nameof(value)); + value = value.Trim(); + + StringBuilder safeValueStr = new(value.Length); + + for (int i = 0; i < value.Length; i++) + { + if (i < value.Length - 1 && char.IsSurrogatePair(value[i], value[i + 1])) + { + safeValueStr.Append(invalidCharacterReplacement); + // Skip both chars that make up this symbol. + i++; + continue; + } + + bool isFirstCharacterOfIdentifier = safeValueStr.Length == 0 || safeValueStr[safeValueStr.Length - 1] == '.'; + bool isValidFirstCharacter = UnicodeCharacterUtilities.IsIdentifierStartCharacter(value[i]); + bool isValidPartCharacter = UnicodeCharacterUtilities.IsIdentifierPartCharacter(value[i]); + + if (isFirstCharacterOfIdentifier && !isValidFirstCharacter && isValidPartCharacter) + { + // This character cannot be at the beginning, but is good otherwise. Prefix it with something valid. + safeValueStr.Append(invalidCharacterReplacement); + safeValueStr.Append(value[i]); + } + else if ((isFirstCharacterOfIdentifier && isValidFirstCharacter) + || (!isFirstCharacterOfIdentifier && isValidPartCharacter) + || (safeValueStr.Length > 0 && i < value.Length - 1 && value[i] == '.')) + { + // This character is allowed to be where it is. + safeValueStr.Append(value[i]); + } + else + { + safeValueStr.Append(invalidCharacterReplacement); + } + } + + return safeValueStr.ToString(); + } +} diff --git a/src/Analyzers/MSTest.SourceGeneration/GlobalSuppressions.cs b/src/Analyzers/MSTest.SourceGeneration/GlobalSuppressions.cs new file mode 100644 index 0000000000..905a836dd9 --- /dev/null +++ b/src/Analyzers/MSTest.SourceGeneration/GlobalSuppressions.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +// This file is used by Code Analysis to maintain SuppressMessage +// attributes that are applied to this project. +// Project-level suppressions either have no target or are given +// a specific target and scoped to a namespace, type, member, etc. using System.Diagnostics.CodeAnalysis; +[assembly: SuppressMessage("Style", "IDE0056:Use index operator", Justification = "Needed when we build with native AOT because there we force net8 target.", Scope = "member", Target = "~M:Microsoft.Testing.Framework.SourceGeneration.ObjectModels.DataRowTestMethodArgumentsInfo.AppendArguments(Microsoft.Testing.Framework.SourceGeneration.Helpers.IndentedStringBuilder)")] +[assembly: SuppressMessage("Style", "IDE0057:Use range operator", Justification = "Needed when we build with native AOT because there we force net8 target.", Scope = "member", Target = "~M:Microsoft.Testing.Framework.SourceGeneration.ObjectModels.DataRowTestMethodArgumentsInfo.AppendArguments(Microsoft.Testing.Framework.SourceGeneration.Helpers.IndentedStringBuilder)")] diff --git a/src/Analyzers/MSTest.SourceGeneration/Helpers/AttributeDataExtensions.cs b/src/Analyzers/MSTest.SourceGeneration/Helpers/AttributeDataExtensions.cs new file mode 100644 index 0000000000..f76b38111a --- /dev/null +++ b/src/Analyzers/MSTest.SourceGeneration/Helpers/AttributeDataExtensions.cs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +using Microsoft.CodeAnalysis; + +namespace Microsoft.Testing.Framework.SourceGeneration.Helpers; + +internal static class AttributeDataExtensions +{ + public static bool TryGetTestExecutionTimeout(this AttributeData attribute, INamedTypeSymbol? executionTimeoutAttributeSymbol, + INamedTypeSymbol? timeSpanSymbol, out TimeSpan executionTimeout) + { + if (!SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, executionTimeoutAttributeSymbol)) + { + executionTimeout = default; + return false; + } + + return TryGetTimeoutValue(attribute, timeSpanSymbol, out executionTimeout); + } + + public static bool TryGetMethodTimeout(this AttributeData attribute, INamedTypeSymbol? methodTimeoutAttributeSymbol, + INamedTypeSymbol? timeSpanSymbol, out TimeSpan methodTimeout) + { + if (!SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, methodTimeoutAttributeSymbol)) + { + methodTimeout = default; + return false; + } + + return TryGetTimeoutValue(attribute, timeSpanSymbol, out methodTimeout); + } + + private static bool TryGetTimeoutValue(this AttributeData attribute, INamedTypeSymbol? timeSpanSymbol, out TimeSpan timeout) + { + if (attribute.ConstructorArguments.Length == 1 + && attribute.ConstructorArguments[0].Type is { } executionTimeoutCtorArgType + && attribute.ConstructorArguments[0].Value is { } executionTimeoutCtorArgValue) + { + if (executionTimeoutCtorArgType.SpecialType is SpecialType.System_Int32 or SpecialType.System_Int64) + { + timeout = TimeSpan.FromMilliseconds((int)executionTimeoutCtorArgValue); + return true; + } + else if (SymbolEqualityComparer.Default.Equals(executionTimeoutCtorArgType, timeSpanSymbol)) + { + timeout = (TimeSpan)executionTimeoutCtorArgValue; + return true; + } + } + + timeout = default; + return false; + } +} diff --git a/src/Analyzers/MSTest.SourceGeneration/Helpers/Constants.cs b/src/Analyzers/MSTest.SourceGeneration/Helpers/Constants.cs new file mode 100644 index 0000000000..7a968202d0 --- /dev/null +++ b/src/Analyzers/MSTest.SourceGeneration/Helpers/Constants.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +namespace Microsoft.Testing.Framework.SourceGeneration; + +internal static class Constants +{ + public const string NewLine = "\r\n"; +} diff --git a/src/Analyzers/MSTest.SourceGeneration/Helpers/DiagnosticCategory.cs b/src/Analyzers/MSTest.SourceGeneration/Helpers/DiagnosticCategory.cs new file mode 100644 index 0000000000..b5d8a10038 --- /dev/null +++ b/src/Analyzers/MSTest.SourceGeneration/Helpers/DiagnosticCategory.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +namespace Microsoft.Testing.Framework.SourceGeneration; + +internal static class DiagnosticCategory +{ + public const string Usage = nameof(Usage); +} diff --git a/src/Analyzers/MSTest.SourceGeneration/Helpers/DiagnosticExtensions.cs b/src/Analyzers/MSTest.SourceGeneration/Helpers/DiagnosticExtensions.cs new file mode 100644 index 0000000000..a4799be461 --- /dev/null +++ b/src/Analyzers/MSTest.SourceGeneration/Helpers/DiagnosticExtensions.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +using System.Collections.Immutable; + +using Microsoft.CodeAnalysis; + +namespace Microsoft.Testing.Framework.SourceGeneration; + +internal static class DiagnosticExtensions +{ + public static Diagnostic CreateDiagnostic(this IEnumerable locations, DiagnosticDescriptor rule, + params object[] args) + => locations.CreateDiagnostic(rule, null, args); + + public static Diagnostic CreateDiagnostic(this IEnumerable locations, DiagnosticDescriptor rule, + ImmutableDictionary? properties, params object[] args) + { + var inSource = locations.Where(l => l.IsInSource).ToImmutableArray(); + return !inSource.Any() + ? Diagnostic.Create(rule, null, args) + : Diagnostic.Create( + rule, + location: inSource.First(), + additionalLocations: inSource.Skip(1), + properties: properties, + messageArgs: args); + } + + public static Diagnostic CreateDiagnostic(this ISymbol symbol, DiagnosticDescriptor rule, params object[] args) + => symbol.Locations.CreateDiagnostic(rule, args); +} diff --git a/src/Analyzers/MSTest.SourceGeneration/Helpers/DisposableAction.cs b/src/Analyzers/MSTest.SourceGeneration/Helpers/DisposableAction.cs new file mode 100644 index 0000000000..e3a0266363 --- /dev/null +++ b/src/Analyzers/MSTest.SourceGeneration/Helpers/DisposableAction.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +namespace Microsoft.Testing.Framework.SourceGeneration.Helpers; + +internal readonly struct DisposableAction : IDisposable +{ + public Action Action { get; } + + public DisposableAction(Action action) => Action = action; + + public void Dispose() => Action(); +} diff --git a/src/Analyzers/MSTest.SourceGeneration/Helpers/EnumerableExtensions.cs b/src/Analyzers/MSTest.SourceGeneration/Helpers/EnumerableExtensions.cs new file mode 100644 index 0000000000..24830dd662 --- /dev/null +++ b/src/Analyzers/MSTest.SourceGeneration/Helpers/EnumerableExtensions.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +namespace Microsoft.Testing.Framework.SourceGeneration.Helpers; + +internal static class EnumerableExtensions +{ + private static readonly Func NotNullTest = x => x != null; + + public static IEnumerable WhereNotNull(this IEnumerable source) + where T : class + => source.Where((Func)NotNullTest)!; +} diff --git a/src/Analyzers/MSTest.SourceGeneration/Helpers/ExceptionUtils.cs b/src/Analyzers/MSTest.SourceGeneration/Helpers/ExceptionUtils.cs new file mode 100644 index 0000000000..084f032795 --- /dev/null +++ b/src/Analyzers/MSTest.SourceGeneration/Helpers/ExceptionUtils.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +namespace Microsoft.Testing.Framework.SourceGeneration.Helpers; + +internal static class ApplicationStateGuard +{ + internal static InvalidOperationException Unreachable([CallerFilePath] string? path = null, [CallerLineNumber] int line = 0) + => new($"This program location is thought to be unreachable. File='{path}' Line={line}"); +} diff --git a/src/Analyzers/MSTest.SourceGeneration/Helpers/FileHeaderUtils.cs b/src/Analyzers/MSTest.SourceGeneration/Helpers/FileHeaderUtils.cs new file mode 100644 index 0000000000..3474a7585e --- /dev/null +++ b/src/Analyzers/MSTest.SourceGeneration/Helpers/FileHeaderUtils.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +namespace Microsoft.Testing.Framework.SourceGeneration.Helpers; + +internal static class FileHeaderUtils +{ + public static void AppendAutoGeneratedHeader(this IndentedStringBuilder stringBuilder) + { + stringBuilder.AppendLine("//------------------------------------------------------------------------------"); + stringBuilder.AppendLine("// "); + stringBuilder.AppendLine("// This code was generated by Microsoft Testing Framework Generator."); + stringBuilder.AppendLine("// "); + stringBuilder.AppendLine("//------------------------------------------------------------------------------"); + } +} diff --git a/src/Analyzers/MSTest.SourceGeneration/Helpers/IMethodSymbolExtensions.cs b/src/Analyzers/MSTest.SourceGeneration/Helpers/IMethodSymbolExtensions.cs new file mode 100644 index 0000000000..344b0a3da7 --- /dev/null +++ b/src/Analyzers/MSTest.SourceGeneration/Helpers/IMethodSymbolExtensions.cs @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +using Microsoft.CodeAnalysis; + +namespace Microsoft.Testing.Framework.SourceGeneration; + +internal static class IMethodSymbolExtensions +{/// + /// Checks if the given method implements or overrides an implementation of . + /// + public static bool IsDisposeImplementation(this IMethodSymbol? method, INamedTypeSymbol? iDisposable) + { + if (method is null) + { + return false; + } + + if (method.IsOverride) + { + return method.OverriddenMethod.IsDisposeImplementation(iDisposable); + } + + // Identify the implementor of IDisposable.Dispose in the given method's containing type and check + // if it is the given method. + return method.ReturnsVoid + && method.Parameters.IsEmpty + && method.IsImplementationOfInterfaceMethod(null, iDisposable, "Dispose"); + } + + /// + /// Checks if the given method implements "IAsyncDisposable.Dispose" or overrides an implementation of "IAsyncDisposable.Dispose". + /// + public static bool IsAsyncDisposeImplementation(this IMethodSymbol? method, INamedTypeSymbol? iAsyncDisposable, INamedTypeSymbol? valueTaskType) + { + if (method is null) + { + return false; + } + + if (method.IsOverride) + { + return method.OverriddenMethod.IsAsyncDisposeImplementation(iAsyncDisposable, valueTaskType); + } + + // Identify the implementor of IAsyncDisposable.Dispose in the given method's containing type and check + // if it is the given method. + return SymbolEqualityComparer.Default.Equals(method.ReturnType, valueTaskType) + && method.Parameters.IsEmpty + && method.IsImplementationOfInterfaceMethod(null, iAsyncDisposable, "DisposeAsync"); + } + + /// + /// Checks if the given method is an implementation of the given interface method + /// Substituted with the given typeargument. + /// + public static bool IsImplementationOfInterfaceMethod(this IMethodSymbol method, ITypeSymbol? typeArgument, INamedTypeSymbol? interfaceType, string interfaceMethodName) + { + INamedTypeSymbol? constructedInterface = typeArgument != null ? interfaceType?.Construct(typeArgument) : interfaceType; + + return constructedInterface?.GetMembers(interfaceMethodName).FirstOrDefault() is IMethodSymbol interfaceMethod + && SymbolEqualityComparer.Default.Equals(method, method.ContainingType.FindImplementationForInterfaceMember(interfaceMethod)); + } +} diff --git a/src/Analyzers/MSTest.SourceGeneration/Helpers/ISymbolExtensions.cs b/src/Analyzers/MSTest.SourceGeneration/Helpers/ISymbolExtensions.cs new file mode 100644 index 0000000000..a8a4823ca9 --- /dev/null +++ b/src/Analyzers/MSTest.SourceGeneration/Helpers/ISymbolExtensions.cs @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +using Microsoft.CodeAnalysis; + +namespace Analyzers.Utilities; + +internal static class ISymbolExtensions +{ + public static SymbolVisibility GetResultantVisibility(this ISymbol symbol) + { + // Start by assuming it's visible. + SymbolVisibility visibility = SymbolVisibility.Public; + + switch (symbol.Kind) + { + case SymbolKind.Alias: + // Aliases are uber private. They're only visible in the same file that they + // were declared in. + return SymbolVisibility.Private; + + case SymbolKind.Parameter: + // Parameters are only as visible as their containing symbol + return symbol.ContainingSymbol.GetResultantVisibility(); + + case SymbolKind.TypeParameter: + // Type Parameters are private. + return SymbolVisibility.Private; + } + + while (symbol != null && symbol.Kind != SymbolKind.Namespace) + { + switch (symbol.DeclaredAccessibility) + { + // If we see anything private, then the symbol is private. + case Accessibility.NotApplicable: + case Accessibility.Private: + return SymbolVisibility.Private; + + // If we see anything internal, then knock it down from public to + // internal. + case Accessibility.Internal: + case Accessibility.ProtectedAndInternal: + visibility = SymbolVisibility.Internal; + break; + + // For anything else (Public, Protected, ProtectedOrInternal), the + // symbol stays at the level we've gotten so far. + } + + symbol = symbol.ContainingSymbol; + } + + return visibility; + } + + public static ITypeSymbol? GetMemberType(this ISymbol? symbol) + => symbol switch + { + IEventSymbol eventSymbol => eventSymbol.Type, + IFieldSymbol fieldSymbol => fieldSymbol.Type, + IMethodSymbol methodSymbol => methodSymbol.ReturnType, + IPropertySymbol propertySymbol => propertySymbol.Type, + _ => null, + }; +} diff --git a/src/Analyzers/MSTest.SourceGeneration/Helpers/ITypeSymbolExtensions.cs b/src/Analyzers/MSTest.SourceGeneration/Helpers/ITypeSymbolExtensions.cs new file mode 100644 index 0000000000..0af0a3975a --- /dev/null +++ b/src/Analyzers/MSTest.SourceGeneration/Helpers/ITypeSymbolExtensions.cs @@ -0,0 +1,114 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +using System.Collections.Immutable; + +using Microsoft.CodeAnalysis; + +namespace Analyzers.Utilities; + +internal static class ITypeSymbolExtensions +{ + /// + /// Returns the members of the given type, including inherited members. + /// + public static IEnumerable> GetAllMembers(this INamedTypeSymbol symbol) + { + INamedTypeSymbol? currentSymbol = symbol; + + while (currentSymbol != null) + { + yield return currentSymbol.GetMembers(); + currentSymbol = currentSymbol.BaseType; + } + } + + public static IEnumerable> GetAllMembers(this INamedTypeSymbol symbol, string name) + { + INamedTypeSymbol? currentSymbol = symbol; + + while (currentSymbol != null) + { + yield return currentSymbol.GetMembers(name); + currentSymbol = currentSymbol.BaseType; + } + } + + public static bool Inherits( + [NotNullWhen(returnValue: true)] this ITypeSymbol? type, + [NotNullWhen(returnValue: true)] ITypeSymbol? possibleBase) + { + if (type == null || possibleBase == null) + { + return false; + } + + switch (possibleBase.TypeKind) + { + case TypeKind.Class: + if (type.TypeKind == TypeKind.Interface) + { + return false; + } + + return DerivesFrom(type, possibleBase, baseTypesOnly: true); + + case TypeKind.Interface: + return DerivesFrom(type, possibleBase); + + default: + return false; + } + } + + public static bool DerivesFrom( + [NotNullWhen(returnValue: true)] this ITypeSymbol? symbol, + [NotNullWhen(returnValue: true)] ITypeSymbol? candidateBaseType, + bool baseTypesOnly = false, + bool checkTypeParameterConstraints = true) + { + if (candidateBaseType == null || symbol == null) + { + return false; + } + + if (!baseTypesOnly && candidateBaseType.TypeKind == TypeKind.Interface) + { + IEnumerable allInterfaces = symbol.AllInterfaces.OfType(); + if (SymbolEqualityComparer.Default.Equals(candidateBaseType.OriginalDefinition, candidateBaseType)) + { + // Candidate base type is not a constructed generic type, so use original definition for interfaces. + allInterfaces = allInterfaces.Select(i => i.OriginalDefinition); + } + + if (allInterfaces.Contains(candidateBaseType, SymbolEqualityComparer.Default)) + { + return true; + } + } + + if (checkTypeParameterConstraints && symbol.TypeKind == TypeKind.TypeParameter) + { + var typeParameterSymbol = (ITypeParameterSymbol)symbol; + foreach (ITypeSymbol constraintType in typeParameterSymbol.ConstraintTypes) + { + if (constraintType.DerivesFrom(candidateBaseType, baseTypesOnly, checkTypeParameterConstraints)) + { + return true; + } + } + } + + while (symbol != null) + { + if (SymbolEqualityComparer.Default.Equals(symbol, candidateBaseType)) + { + return true; + } + + symbol = symbol.BaseType; + } + + return false; + } +} diff --git a/src/Analyzers/MSTest.SourceGeneration/Helpers/IncrementalValuesProviderExtensions.cs b/src/Analyzers/MSTest.SourceGeneration/Helpers/IncrementalValuesProviderExtensions.cs new file mode 100644 index 0000000000..bef8c4073b --- /dev/null +++ b/src/Analyzers/MSTest.SourceGeneration/Helpers/IncrementalValuesProviderExtensions.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +using Microsoft.CodeAnalysis; + +namespace Microsoft.Testing.Framework.SourceGeneration.Helpers; + +internal static class IncrementalValuesProviderExtensions +{ + private static readonly Func NotNullTest = x => x != null; + + public static IncrementalValuesProvider WhereNotNull(this IncrementalValuesProvider source) + where T : class + => source.Where(NotNullTest)!; +} diff --git a/src/Analyzers/MSTest.SourceGeneration/Helpers/IndentedStringBuilder.cs b/src/Analyzers/MSTest.SourceGeneration/Helpers/IndentedStringBuilder.cs new file mode 100644 index 0000000000..39fdb20ba0 --- /dev/null +++ b/src/Analyzers/MSTest.SourceGeneration/Helpers/IndentedStringBuilder.cs @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +namespace Microsoft.Testing.Framework.SourceGeneration.Helpers; + +internal sealed class IndentedStringBuilder +{ + private readonly StringBuilder _builder = new(); + private bool _needsIndent = true; + + public IndentedStringBuilder(int indentationLevel = 0) => IndentationLevel = indentationLevel; + + public int IndentationLevel { get; internal set; } + + public void Append(char value) + { + MaybeAppendIndent(); + _builder.Append(value); + _needsIndent = false; + } + + public void Append(string value) + { + MaybeAppendIndent(); + _builder.Append(value); + _needsIndent = false; + } + + public void AppendLine() + { + _builder.Append(Constants.NewLine); + _needsIndent = true; + } + + public void AppendLine(char value) + { + MaybeAppendIndent().Append(value).Append(Constants.NewLine); + _needsIndent = true; + } + + public void AppendLine(string value) + { + MaybeAppendIndent().Append(value).Append(Constants.NewLine); + _needsIndent = true; + } + + public void AppendUnindentedLine(string value) + => _builder.Append(value).Append(Constants.NewLine); + + public IDisposable AppendBlock(string? value = null, char? closingBraceSuffixChar = null) + { + if (value is not null) + { + AppendLine(value); + } + + AppendLine('{'); + IndentationLevel++; + + return new DisposableAction(() => + { + IndentationLevel--; + Append('}'); + if (closingBraceSuffixChar is not null) + { + Append(closingBraceSuffixChar.Value); + } + + AppendLine(); + }); + } + + public override string ToString() => _builder.ToString(); + + private StringBuilder MaybeAppendIndent() + { + if (_needsIndent) + { + _builder.Append(new string(' ', IndentationLevel * 4)); + } + + return _builder; + } +} diff --git a/src/Analyzers/MSTest.SourceGeneration/Helpers/SymbolVisibility.cs b/src/Analyzers/MSTest.SourceGeneration/Helpers/SymbolVisibility.cs new file mode 100644 index 0000000000..db5e594e9e --- /dev/null +++ b/src/Analyzers/MSTest.SourceGeneration/Helpers/SymbolVisibility.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +// Copied from https://github.com/dotnet/roslyn-analyzers/blob/main/src/Utilities/Compiler/Extensions/SymbolVisibility.cs +namespace Analyzers.Utilities; + +#pragma warning disable CA1027 // Mark enums with FlagsAttribute +internal enum SymbolVisibility +#pragma warning restore CA1027 // Mark enums with FlagsAttribute +{ + Public = 0, + Internal = 1, + Private = 2, + Friend = Internal, +} + +/// +/// Extensions for . +/// WellKnownTypeProvider +internal static class SymbolVisibilityExtensions +{ + /// + /// Determines whether is at least as visible as . + /// + /// The visibility to compare against. + /// The visibility to compare with. + /// True if one can say that is at least as visible as . + /// + /// For example, is at least as visible as , but is not as visible as . + /// + public static bool IsAtLeastAsVisibleAs(this SymbolVisibility typeVisibility, SymbolVisibility comparisonVisibility) + => typeVisibility switch + { + SymbolVisibility.Public => true, + SymbolVisibility.Internal => comparisonVisibility != SymbolVisibility.Public, + SymbolVisibility.Private => comparisonVisibility == SymbolVisibility.Private, + _ => throw new ArgumentOutOfRangeException(nameof(typeVisibility), typeVisibility, null), + }; +} diff --git a/src/Analyzers/MSTest.SourceGeneration/Helpers/SystemPolyfills.cs b/src/Analyzers/MSTest.SourceGeneration/Helpers/SystemPolyfills.cs new file mode 100644 index 0000000000..6b6fe8dda3 --- /dev/null +++ b/src/Analyzers/MSTest.SourceGeneration/Helpers/SystemPolyfills.cs @@ -0,0 +1,198 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +#if !NETCOREAPP + +using System.ComponentModel; + +namespace System.Runtime.CompilerServices +{ + + [EditorBrowsable(EditorBrowsableState.Never)] + internal class IsExternalInit { } +} + +// This was copied from https://github.com/dotnet/coreclr/blob/60f1e6265bd1039f023a82e0643b524d6aaf7845/src/System.Private.CoreLib/shared/System/Diagnostics/CodeAnalysis/NullableAttributes.cs +// and updated to have the scope of the attributes be internal. +namespace System.Diagnostics.CodeAnalysis +{ + /// Specifies that null is allowed as an input even if the corresponding type disallows it. + [AttributeUsage(AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property, Inherited = false)] + internal sealed class AllowNullAttribute : Attribute { } + + /// Specifies that null is disallowed as an input even if the corresponding type allows it. + [AttributeUsage(AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property, Inherited = false)] + internal sealed class DisallowNullAttribute : Attribute { } + + /// Specifies that an output may be null even if the corresponding type disallows it. + [AttributeUsage(AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property | AttributeTargets.ReturnValue, Inherited = false)] + internal sealed class MaybeNullAttribute : Attribute { } + + /// Specifies that when a method returns , the parameter may be null even if the corresponding type disallows it. + [AttributeUsage(AttributeTargets.Parameter, Inherited = false)] + internal sealed class MaybeNullWhenAttribute : Attribute + { + /// Initializes the attribute with the specified return value condition. + /// + /// The return value condition. If the method returns this value, the associated parameter may be null. + /// + public MaybeNullWhenAttribute(bool returnValue) => ReturnValue = returnValue; + + /// Gets the return value condition. + public bool ReturnValue { get; } + } + + /// Specifies that the output will be non-null if the named parameter is non-null. + [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property | AttributeTargets.ReturnValue, AllowMultiple = true, Inherited = false)] + internal sealed class NotNullIfNotNullAttribute : Attribute + { + /// Initializes the attribute with the associated parameter name. + /// + /// The associated parameter name. The output will be non-null if the argument to the parameter specified is non-null. + /// + public NotNullIfNotNullAttribute(string parameterName) => ParameterName = parameterName; + + /// Gets the associated parameter name. + public string ParameterName { get; } + } + + /// Specifies that when a method returns , the parameter will not be null even if the corresponding type allows it. + [AttributeUsage(AttributeTargets.Parameter, Inherited = false)] + internal sealed class NotNullWhenAttribute : Attribute + { + /// Initializes the attribute with the specified return value condition. + /// + /// The return value condition. If the method returns this value, the associated parameter will not be null. + /// + public NotNullWhenAttribute(bool returnValue) => ReturnValue = returnValue; + + /// Gets the return value condition. + public bool ReturnValue { get; } + } + + /// Specifies that the method or property will ensure that the listed field and property members have not-null values. + [AttributeUsage(AttributeTargets.Method | AttributeTargets.Property, Inherited = false, AllowMultiple = true)] + internal sealed class MemberNotNullAttribute : Attribute + { + /// Initializes the attribute with a field or property member. + /// + /// The field or property member that is promised to be not-null. + /// + public MemberNotNullAttribute(string member) => Members = new[] { member }; + + /// Initializes the attribute with the list of field and property members. + /// + /// The list of field and property members that are promised to be not-null. + /// + public MemberNotNullAttribute(params string[] members) => Members = members; + + /// Gets field or property member names. + public string[] Members { get; } + } + + /// Specifies that the method or property will ensure that the listed field and property members have not-null values when returning with the specified return value condition. + [AttributeUsage(AttributeTargets.Method | AttributeTargets.Property, Inherited = false, AllowMultiple = true)] + internal sealed class MemberNotNullWhenAttribute : Attribute + { + /// Initializes the attribute with the specified return value condition and a field or property member. + /// + /// The return value condition. If the method returns this value, the associated parameter will not be null. + /// + /// + /// The field or property member that is promised to be not-null. + /// + public MemberNotNullWhenAttribute(bool returnValue, string member) + { + ReturnValue = returnValue; + Members = new[] { member }; + } + + /// Initializes the attribute with the specified return value condition and list of field and property members. + /// + /// The return value condition. If the method returns this value, the associated parameter will not be null. + /// + /// + /// The list of field and property members that are promised to be not-null. + /// + public MemberNotNullWhenAttribute(bool returnValue, params string[] members) + { + ReturnValue = returnValue; + Members = members; + } + + /// Gets the return value condition. + public bool ReturnValue { get; } + + /// Gets field or property member names. + public string[] Members { get; } + } +} + +#endif + +#if !NET7_0_OR_GREATER +namespace System.Diagnostics.CodeAnalysis +{ + /// + /// Specifies that this constructor sets all required members for the current type, + /// and callers do not need to set any required members themselves. + /// + [AttributeUsage(AttributeTargets.Constructor, AllowMultiple = false, Inherited = false)] + internal sealed class SetsRequiredMembersAttribute : Attribute + { + } +} + +namespace System.Runtime.CompilerServices +{ + /// + /// Indicates that compiler support for a particular feature is required for the location where this attribute is applied. + /// + [AttributeUsage(AttributeTargets.All, AllowMultiple = true, Inherited = false)] + internal sealed class CompilerFeatureRequiredAttribute : Attribute + { + /// + /// Creates a new instance of the type. + /// + /// The name of the feature to indicate. + public CompilerFeatureRequiredAttribute(string featureName) + { + FeatureName = featureName; + } + + /// + /// The name of the compiler feature. + /// + public string FeatureName { get; } + + /// + /// If true, the compiler can choose to allow access to the location where this attribute is applied if it does not understand . + /// + public bool IsOptional { get; set; } + + /// + /// The used for the ref structs C# feature. + /// + public const string RefStructs = nameof(RefStructs); + + /// + /// The used for the required members C# feature. + /// + public const string RequiredMembers = nameof(RequiredMembers); + } + + /// + /// Specifies that a type has required members or that a member is required. + /// + [AttributeUsage( + AttributeTargets.Class | + AttributeTargets.Struct | + AttributeTargets.Field | + AttributeTargets.Property, + AllowMultiple = false, + Inherited = false)] + internal sealed class RequiredMemberAttribute : Attribute + { + } +} +#endif diff --git a/src/Analyzers/MSTest.SourceGeneration/Helpers/TestMethods.cs b/src/Analyzers/MSTest.SourceGeneration/Helpers/TestMethods.cs new file mode 100644 index 0000000000..9b605bbc0c --- /dev/null +++ b/src/Analyzers/MSTest.SourceGeneration/Helpers/TestMethods.cs @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +using Microsoft.CodeAnalysis; + +namespace Microsoft.Testing.Framework.SourceGeneration; + +internal static class TestMethods +{ + public static SymbolDisplayFormat MethodIdentifierFullyQualifiedTypeFormat { get; } = + SymbolDisplayFormat.CSharpErrorMessageFormat.WithMiscellaneousOptions( + SymbolDisplayMiscellaneousOptions.EscapeKeywordIdentifiers | + SymbolDisplayMiscellaneousOptions.UseAsterisksInMultiDimensionalArrays | + SymbolDisplayMiscellaneousOptions.UseErrorTypeSymbolName | + SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier); + + public static bool IsValidTestMethodShape(this IMethodSymbol methodSymbol, WellKnownTypes wellKnownTypes) + { + // We only look for public methods + if (methodSymbol.DeclaredAccessibility != Accessibility.Public) + { + return false; + } + + // We don't support generic test methods + if (!methodSymbol.TypeParameters.IsEmpty) + { + return false; + } + + // We don't support static test methods + if (methodSymbol.IsStatic) + { + return false; + } + + // We accept only simple methods + if (methodSymbol.MethodKind != MethodKind.Ordinary + || methodSymbol.IsAbstract + || methodSymbol.IsExtern + || methodSymbol.IsVirtual + || methodSymbol.IsOverride + || methodSymbol.IsImplicitlyDeclared + || methodSymbol.IsPartialDefinition) + { + return false; + } + + // We don't support async void + if (methodSymbol.ReturnsVoid && methodSymbol.IsAsync) + { + return false; + } + + // We support only void and Task return methods + if (!methodSymbol.ReturnsVoid + && !SymbolEqualityComparer.Default.Equals(methodSymbol.ReturnType, wellKnownTypes.TaskSymbol) + && !SymbolEqualityComparer.Default.Equals(methodSymbol.ReturnType, wellKnownTypes.ValueTaskSymbol)) + { + return false; + } + + // Method has correct shape to be a test method + return true; + } + + /// + /// Method has a test method shape but is known to not be a test method. + /// + public static bool IsKnownNonTestMethod(this IMethodSymbol methodSymbol, WellKnownTypes wellKnownTypes) + => methodSymbol.IsDisposeImplementation(wellKnownTypes.IDisposableSymbol) + || methodSymbol.IsAsyncDisposeImplementation(wellKnownTypes.IAsyncDisposableSymbol, wellKnownTypes.ValueTaskSymbol) + || methodSymbol.GetAttributes().Any(attr => SymbolEqualityComparer.Default.Equals(attr.AttributeClass, wellKnownTypes.IgnoreAttributeSymbol)); +} diff --git a/src/Analyzers/MSTest.SourceGeneration/Helpers/TestNodeHelpers.cs b/src/Analyzers/MSTest.SourceGeneration/Helpers/TestNodeHelpers.cs new file mode 100644 index 0000000000..bad3bc569e --- /dev/null +++ b/src/Analyzers/MSTest.SourceGeneration/Helpers/TestNodeHelpers.cs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +namespace Microsoft.Testing.Framework.SourceGeneration.Helpers; + +internal static class TestNodeHelpers +{ + public static string GenerateEscapedName(string name) + => name.Replace(".", "_"); + + public static DisposableAction AppendTestNode(this IndentedStringBuilder nodeStringBuilder, string stableUid, string displayName, + ICollection properties, char testNodeBlockSuffixChar = ',') + { + IDisposable testNodeBlock = AppendTestNodeCommonPart(nodeStringBuilder, stableUid, displayName, properties, testNodeBlockSuffixChar); + IDisposable testsBlock = nodeStringBuilder.AppendBlock("Tests = new MSTF::TestNode[]", closingBraceSuffixChar: ','); + + return new DisposableAction(() => + { + testsBlock.Dispose(); + testNodeBlock.Dispose(); + }); + } + + public static DisposableAction AppendTestNode(this IndentedStringBuilder nodeStringBuilder, string stableUid, string displayName, + ICollection properties, string testsVariableName, char testNodeBlockSuffixChar = ',') + { + IDisposable testNodeBlock = AppendTestNodeCommonPart(nodeStringBuilder, stableUid, displayName, properties, testNodeBlockSuffixChar); + nodeStringBuilder.AppendLine($"Tests = {testsVariableName}.ToArray(),"); + + return new DisposableAction(testNodeBlock.Dispose); + } + + private static IDisposable AppendTestNodeCommonPart(IndentedStringBuilder nodeStringBuilder, string stableUid, string displayName, + ICollection properties, char testNodeBlockSuffixChar = ',') + { + IDisposable testNodeBlock = nodeStringBuilder.AppendBlock("new MSTF::TestNode", testNodeBlockSuffixChar); + nodeStringBuilder.AppendLine($"StableUid = \"{stableUid}\","); + nodeStringBuilder.AppendLine($"DisplayName = \"{displayName}\","); + + if (properties.Count > 0) + { + using (nodeStringBuilder.AppendBlock($"Properties = new Msg::IProperty[{properties.Count}]", closingBraceSuffixChar: ',')) + { + foreach (string property in properties) + { + nodeStringBuilder.AppendLine(property); + } + } + } + else + { + nodeStringBuilder.AppendLine("Properties = Sys::Array.Empty(),"); + } + + return testNodeBlock; + } +} diff --git a/src/Analyzers/MSTest.SourceGeneration/Helpers/UnicodeCharacterUtilities.cs b/src/Analyzers/MSTest.SourceGeneration/Helpers/UnicodeCharacterUtilities.cs new file mode 100644 index 0000000000..0f5dc97fa8 --- /dev/null +++ b/src/Analyzers/MSTest.SourceGeneration/Helpers/UnicodeCharacterUtilities.cs @@ -0,0 +1,124 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +namespace Microsoft.Testing.Framework.SourceGeneration.Helpers; + +/// +/// Defines a set of helper methods to classify Unicode characters. +/// +internal static class UnicodeCharacterUtilities +{ + public static bool IsIdentifierStartCharacter(char ch) + { + // identifier-start-character: + // letter-character + // _ (the underscore character U+005F) + if (ch < 'a') // '\u0061' + { + if (ch < 'A') // '\u0041' + { + return false; + } + + return ch is <= 'Z' // '\u005A' + or '_'; // '\u005F' + } + + if (ch <= 'z') // '\u007A' + { + return true; + } + + if (ch <= '\u007F') // max ASCII + { + return false; + } + + // Check if letter-character + return IsLetterChar(CharUnicodeInfo.GetUnicodeCategory(ch)); + } + + /// + /// Returns true if the Unicode character can be a part of an identifier. + /// + /// The Unicode character. + public static bool IsIdentifierPartCharacter(char ch) + { + // identifier-part-character: + // letter-character + // decimal-digit-character + // connecting-character + // combining-character + // formatting-character + if (ch < 'a') // '\u0061' + { + if (ch < 'A') // '\u0041' + { + return ch is >= '0' // '\u0030' + and <= '9'; // '\u0039' + } + + return ch is <= 'Z' // '\u005A' + or '_'; // '\u005F' + } + + if (ch <= 'z') // '\u007A' + { + return true; + } + + if (ch <= '\u007F') // max ASCII + { + return false; + } + + UnicodeCategory cat = CharUnicodeInfo.GetUnicodeCategory(ch); + return IsLetterChar(cat) + || IsDecimalDigitChar(cat) + || IsConnectingChar(cat) + || IsCombiningChar(cat) + || IsFormattingChar(cat); + } + + private static bool IsLetterChar(UnicodeCategory cat) + // letter-character: + // A Unicode character of classes Lu, Ll, Lt, Lm, Lo, or Nl + // A Unicode-escape-sequence representing a character of classes Lu, Ll, Lt, Lm, Lo, or Nl + => cat switch + { + UnicodeCategory.UppercaseLetter or UnicodeCategory.LowercaseLetter or UnicodeCategory.TitlecaseLetter or UnicodeCategory.ModifierLetter or UnicodeCategory.OtherLetter or UnicodeCategory.LetterNumber => true, + _ => false, + }; + + private static bool IsCombiningChar(UnicodeCategory cat) + // combining-character: + // A Unicode character of classes Mn or Mc + // A Unicode-escape-sequence representing a character of classes Mn or Mc + => cat switch + { + UnicodeCategory.NonSpacingMark or UnicodeCategory.SpacingCombiningMark => true, + _ => false, + }; + + private static bool IsDecimalDigitChar(UnicodeCategory cat) + // decimal-digit-character: + // A Unicode character of the class Nd + // A unicode-escape-sequence representing a character of the class Nd + => cat == UnicodeCategory.DecimalDigitNumber; + + private static bool IsConnectingChar(UnicodeCategory cat) + // connecting-character: + // A Unicode character of the class Pc + // A unicode-escape-sequence representing a character of the class Pc + => cat == UnicodeCategory.ConnectorPunctuation; + + /// + /// Returns true if the Unicode character is a formatting character (Unicode class Cf). + /// + /// The Unicode character. + private static bool IsFormattingChar(UnicodeCategory cat) + // formatting-character: + // A Unicode character of the class Cf + // A unicode-escape-sequence representing a character of the class Cf + => cat == UnicodeCategory.Format; +} diff --git a/src/Analyzers/MSTest.SourceGeneration/Helpers/WellKnownTypes.cs b/src/Analyzers/MSTest.SourceGeneration/Helpers/WellKnownTypes.cs new file mode 100644 index 0000000000..72e5f2724c --- /dev/null +++ b/src/Analyzers/MSTest.SourceGeneration/Helpers/WellKnownTypes.cs @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +using Microsoft.CodeAnalysis; + +namespace Microsoft.Testing.Framework.SourceGeneration; + +/* + * IMPORTANT: Keep the constants, properties and constructor properties assignments in alphabetical order. + */ +internal sealed class WellKnownTypes +{ + private const string MicrosoftVisualStudioTestToolsUnitTestingDataRowAttribute = "Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute"; + private const string MicrosoftVisualStudioTestToolsUnitTestingDynamicDataAttribute = "Microsoft.VisualStudio.TestTools.UnitTesting.DynamicDataAttribute"; + private const string MicrosoftVisualStudioTestToolsUnitTestingIgnoreAttribute = "Microsoft.VisualStudio.TestTools.UnitTesting.IgnoreAttribute"; + private const string MicrosoftTestingFrameworkTestArgumentsEntry1 = "Microsoft.Testing.Framework.InternalUnsafeTestArgumentsEntry`1"; + private const string MicrosoftTestingFrameworkTestExecutionTimeoutAttribute = "Microsoft.Testing.Framework.TestExecutionTimeoutAttribute"; + private const string MicrosoftTestingFrameworkTestPropertyAttribute = "Microsoft.Testing.Framework.TestPropertyAttribute"; + private const string SystemCollectionsGenericIEnumerable1 = "System.Collections.Generic.IEnumerable`1"; + private const string SystemIAsyncDisposable = "System.IAsyncDisposable"; + private const string SystemIDisposable = "System.IDisposable"; + private const string SystemObsoleteAttribute = "System.ObsoleteAttribute"; + private const string SystemThreadingTasksTask = "System.Threading.Tasks.Task"; + private const string SystemThreadingTasksValueTask = "System.Threading.Tasks.ValueTask"; + private const string SystemTimeSpan = "System.TimeSpan"; + private const string MicrosoftVisualStudioTestToolsUnitTestingTestMethodAttribute = "Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute"; + + public WellKnownTypes(Compilation compilation) + { + DataRowAttributeSymbol = compilation.GetTypeByMetadataName(MicrosoftVisualStudioTestToolsUnitTestingDataRowAttribute); + DynamicDataAttributeSymbol = compilation.GetTypeByMetadataName(MicrosoftVisualStudioTestToolsUnitTestingDynamicDataAttribute); + IAsyncDisposableSymbol = compilation.GetTypeByMetadataName(SystemIAsyncDisposable); + IDisposableSymbol = compilation.GetTypeByMetadataName(SystemIDisposable); + IEnumerable1Symbol = compilation.GetTypeByMetadataName(SystemCollectionsGenericIEnumerable1); + IgnoreAttributeSymbol = compilation.GetTypeByMetadataName(MicrosoftVisualStudioTestToolsUnitTestingIgnoreAttribute); + SystemObsoleteAttributeSymbol = compilation.GetTypeByMetadataName(SystemObsoleteAttribute); + TaskSymbol = compilation.GetTypeByMetadataName(SystemThreadingTasksTask); + TestArgumentsEntrySymbol = compilation.GetTypeByMetadataName(MicrosoftTestingFrameworkTestArgumentsEntry1); + TestExecutionTimeoutAttributeSymbol = compilation.GetTypeByMetadataName(MicrosoftTestingFrameworkTestExecutionTimeoutAttribute); + TestPropertyAttributeSymbol = compilation.GetTypeByMetadataName(MicrosoftTestingFrameworkTestPropertyAttribute); + TimeSpanSymbol = compilation.GetTypeByMetadataName(SystemTimeSpan); + ValueTaskSymbol = compilation.GetTypeByMetadataName(SystemThreadingTasksValueTask); + TestMethodAttributeSymbol = compilation.GetTypeByMetadataName(MicrosoftVisualStudioTestToolsUnitTestingTestMethodAttribute); + } + + public INamedTypeSymbol? DataRowAttributeSymbol { get; } + + public INamedTypeSymbol? DynamicDataAttributeSymbol { get; } + + public INamedTypeSymbol? IAsyncDisposableSymbol { get; } + + public INamedTypeSymbol? IDisposableSymbol { get; } + + public INamedTypeSymbol? IEnumerable1Symbol { get; } + + public INamedTypeSymbol? IgnoreAttributeSymbol { get; } + + public INamedTypeSymbol? SystemObsoleteAttributeSymbol { get; } + + public INamedTypeSymbol? TaskSymbol { get; } + + public INamedTypeSymbol? TestArgumentsEntrySymbol { get; } + + public INamedTypeSymbol? TestExecutionTimeoutAttributeSymbol { get; } + + public INamedTypeSymbol? TestPropertyAttributeSymbol { get; } + + public INamedTypeSymbol? TimeSpanSymbol { get; } + + public INamedTypeSymbol? ValueTaskSymbol { get; } + + public INamedTypeSymbol? TestMethodAttributeSymbol { get; } +} diff --git a/src/Analyzers/MSTest.SourceGeneration/MSTest.SourceGeneration.csproj b/src/Analyzers/MSTest.SourceGeneration/MSTest.SourceGeneration.csproj new file mode 100644 index 0000000000..89f5985c1d --- /dev/null +++ b/src/Analyzers/MSTest.SourceGeneration/MSTest.SourceGeneration.csproj @@ -0,0 +1,54 @@ + + + + + netstandard2.0 + + true + + false + NU5128 + true + Microsoft.Testing.Framework.SourceGeneration + + + License.txt + + 1.0.0 + alpha + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Analyzers/MSTest.SourceGeneration/ObjectModels/DataRowTestMethodArgumentsInfo.cs b/src/Analyzers/MSTest.SourceGeneration/ObjectModels/DataRowTestMethodArgumentsInfo.cs new file mode 100644 index 0000000000..5380802d1d --- /dev/null +++ b/src/Analyzers/MSTest.SourceGeneration/ObjectModels/DataRowTestMethodArgumentsInfo.cs @@ -0,0 +1,206 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +using System.Collections.Immutable; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.Testing.Framework.SourceGeneration.Helpers; + +namespace Microsoft.Testing.Framework.SourceGeneration.ObjectModels; + +internal sealed class DataRowTestMethodArgumentsInfo : ITestMethodArgumentsInfo +{ + private readonly ImmutableArray> _argumentsRows; + private readonly TestMethodParametersInfo _parametersInfo; + + public bool IsTestArgumentsEntryReturnType { get; } = true; + + public string? GeneratorMethodFullName { get; } + + public DataRowTestMethodArgumentsInfo(ImmutableArray> argumentsRows, TestMethodParametersInfo parametersInfo) + { + _argumentsRows = argumentsRows; + _parametersInfo = parametersInfo; + } + + public static DataRowTestMethodArgumentsInfo? TryBuild(IMethodSymbol methodSymbol, IEnumerable argumentsAttributes, + TestMethodParametersInfo parametersInfo) + { + var argumentsRows = argumentsAttributes.Select(attr => GetInlineArguments(methodSymbol, attr).ToImmutableArray()).ToImmutableArray(); + + return argumentsRows.IsEmpty + ? null + : new(argumentsRows, parametersInfo); + } + + public void AppendArguments(IndentedStringBuilder nodeBuilder) + { + using (nodeBuilder.AppendBlock($"GetArguments = static () => new {TestMethodInfo.TestArgumentsEntryTypeName}<{_parametersInfo.ParametersTuple}>[]", closingBraceSuffixChar: ',')) + { + foreach (ImmutableArray arguments in _argumentsRows) + { + string argumentsEntry = arguments.Length > 1 + ? "(" + string.Join(", ", arguments) + ")" + : arguments[0]; + string argumentsUid = GetArgumentsUid(_parametersInfo.Parameters.Select(x => x.Name).ToArray(), arguments); + nodeBuilder.AppendLine($"new {TestMethodInfo.TestArgumentsEntryTypeName}<{_parametersInfo.ParametersTuple}>({argumentsEntry}, \"{argumentsUid}\"),"); + } + } + } + + private static string GetArgumentsUid(string[] parameterNames, IList arguments) + { + StringBuilder argumentsUidBuilder = new(); + + for (int i = 0; i < arguments.Count; i++) + { + if (i < parameterNames.Length) + { + if (i != 0) + { + argumentsUidBuilder.Append(", "); + } + + argumentsUidBuilder.Append(parameterNames[i]); + argumentsUidBuilder.Append(": "); + } + + EscapeArgument(arguments[i], argumentsUidBuilder); + } + + return argumentsUidBuilder.ToString(); + } + + internal /* for testing purposes */ static void EscapeArgument(string argument, StringBuilder argumentsUidBuilder) + { + int escapeCharCount = 0; + for (int i = 0; i < argument.Length; i++) + { + char currentChar = argument[i]; + + if (currentChar == '\\') + { + escapeCharCount++; + } + else if (currentChar == '"' && escapeCharCount % 2 == 0) + { + argumentsUidBuilder.Append('\\'); + escapeCharCount = 0; + } + else + { + escapeCharCount = 0; + } + + argumentsUidBuilder.Append(argument[i]); + } + } + + private static IEnumerable GetInlineArguments(IMethodSymbol methodSymbol, AttributeData attr) + { + TypedConstant argumentsAttributeArguments = attr.ConstructorArguments[0]; + if (argumentsAttributeArguments.IsNull) + { + yield return "null"; + yield break; + } + + StringBuilder argumentsBuilder = new(); + Stack<(TypedConstant Argument, bool HasNextArg, bool IsInArray, int ClosingCurlyBraceCount)> argumentStack = new(); + + bool hasManyArgsButExpectsSingleArray = + methodSymbol.Parameters.Length == 1 + && methodSymbol.Parameters[0].Type.TypeKind == TypeKind.Array + && argumentsAttributeArguments.Values.Length > 1; + + if (hasManyArgsButExpectsSingleArray) + { + argumentStack.Push((argumentsAttributeArguments, HasNextArg: false, IsInArray: false, ClosingCurlyBraceCount: 0)); + } + else + { + // We are using the ctor with params object[]; + if (argumentsAttributeArguments.Kind == TypedConstantKind.Array) + { + for (int i = argumentsAttributeArguments.Values.Length - 1; i >= 0; i--) + { + argumentStack.Push((argumentsAttributeArguments.Values[i], + HasNextArg: i < argumentsAttributeArguments.Values.Length - 1, + IsInArray: false, + ClosingCurlyBraceCount: 0)); + } + } + else + { + argumentStack.Push((argumentsAttributeArguments, + HasNextArg: false, + IsInArray: false, + ClosingCurlyBraceCount: 0)); + } + } + + while (argumentStack.Count > 0) + { + (TypedConstant argument, bool hasNextArg, bool isInArray, int closingCurlyBraceCount) = argumentStack.Pop(); + + if (argument.Kind == TypedConstantKind.Array) + { + argumentsBuilder.Append("new "); + if (argument.Type is not null) + { + argumentsBuilder.Append(argument.Type.ToDisplayString()); + } + else + { + argumentsBuilder.Append("[]"); + } + + argumentsBuilder.Append(" { "); + + for (int i = argument.Values.Length - 1; i >= 0; i--) + { + argumentStack.Push((argument.Values[i], + HasNextArg: i < argument.Values.Length - 1 || hasNextArg, + IsInArray: true, + ClosingCurlyBraceCount: i == argument.Values.Length - 1 ? closingCurlyBraceCount + 1 : 0)); + } + } + else + { + if (argument.Kind == TypedConstantKind.Enum) + { + // We could cast the argument to TypedConstant and get the full name of the type + // with the global:: prefix from e.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + // but then we don't have an easy way to get the value in CSharp format without the type. + // So we just prepend. + argumentsBuilder.Append("global::" + argument.ToCSharpString()); + } + else + { + argumentsBuilder.Append(argument.ToCSharpString()); + } + + for (int i = 0; i < closingCurlyBraceCount; i++) + { + argumentsBuilder.Append(" }"); + } + + if (hasNextArg) + { + if (isInArray && closingCurlyBraceCount == 0) + { + argumentsBuilder.Append(", "); + } + else + { + yield return argumentsBuilder.ToString(); + argumentsBuilder.Clear(); + } + } + } + } + + yield return argumentsBuilder.ToString(); + } +} diff --git a/src/Analyzers/MSTest.SourceGeneration/ObjectModels/DynamicDataTestMethodArgumentsInfo.cs b/src/Analyzers/MSTest.SourceGeneration/ObjectModels/DynamicDataTestMethodArgumentsInfo.cs new file mode 100644 index 0000000000..e40db92762 --- /dev/null +++ b/src/Analyzers/MSTest.SourceGeneration/ObjectModels/DynamicDataTestMethodArgumentsInfo.cs @@ -0,0 +1,230 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +using Analyzers.Utilities; + +using Microsoft.CodeAnalysis; +using Microsoft.Testing.Framework.SourceGeneration.Helpers; + +namespace Microsoft.Testing.Framework.SourceGeneration.ObjectModels; + +/// +/// Clone of ArgumentsProvider, that is implementing very similar functionality for DynamicData, because there we don't know what types the user is using, so we do basically the same +/// but we cast their data to the assumed types. +/// +internal sealed class DynamicDataTestMethodArgumentsInfo : ITestMethodArgumentsInfo +{ + // Based on DynamicDataSourceType in: + // https://github.com/microsoft/testfx/blob/8cf945ba740034e37e0a16efddad85f6b0fb67bc/src/TestFramework/TestFramework/Attributes/DataSource/DynamicDataAttribute.cs#L17-L36 + private const int DynamicDataSourceTypeProperty = 0; + private const int DynamicDataSourceTypeMethod = 1; + private const int DynamicDataSourceTypeAutoDetect = 2; + + internal const string TestArgumentsEntryTypeName = "MSTF::InternalUnsafeTestArgumentsEntry"; + internal const string DynamicDataNameProviderTypeName = "MSTF::DynamicDataNameProvider"; + private const string TestArgumentsEntryProviderMethodName = nameof(TestArgumentsEntryProviderMethodName); + private const string TestArgumentsEntryProviderMethodType = nameof(TestArgumentsEntryProviderMethodType); + private readonly string _memberName; + private readonly string _memberFullType; + private readonly SymbolKind _memberKind; + private readonly TestMethodParametersInfo _testMethodParameters; + private readonly bool _targetMethodReturnsCollectionOfTestArgumentsEntry; + + private DynamicDataTestMethodArgumentsInfo(string memberName, string memberFullType, SymbolKind memberKind, TestMethodParametersInfo testMethodParameters, + bool targetMemberReturnsCollectionOfTestArgumentsEntry, string? generatorMethodFullName) + { + _memberName = memberName; + _memberFullType = memberFullType; + // This is always true, because it tells the source gen that GetParameters will return collection of TestArgumentsEntry. + // If the target member does not return that type, we will write code to adapt the result to this collection in AppendArguments method below. + IsTestArgumentsEntryReturnType = true; + _targetMethodReturnsCollectionOfTestArgumentsEntry = targetMemberReturnsCollectionOfTestArgumentsEntry; + _memberKind = memberKind; + GeneratorMethodFullName = generatorMethodFullName; + _testMethodParameters = testMethodParameters; + } + + public bool IsTestArgumentsEntryReturnType { get; } + + public string? GeneratorMethodFullName { get; } + + public static DynamicDataTestMethodArgumentsInfo? TryBuild(IMethodSymbol methodSymbol, List argumentsProviderAttributes, + WellKnownTypes wellKnownTypes) + { + // We don't support more than one provider on method at the same time. + if (argumentsProviderAttributes.Count != 1) + { + return null; + } + + // We collect all the providers that match, and they might conflict by parameters, but this also ties us to the actual type + // so user cannot subclass. + if (!SymbolEqualityComparer.Default.Equals(argumentsProviderAttributes[0].AttributeClass, wellKnownTypes.DynamicDataAttributeSymbol)) + { + return null; + } + + AttributeData attribute = argumentsProviderAttributes[0]; + INamedTypeSymbol memberTypeSymbol = methodSymbol.ContainingType; + string? memberName = null; + int memberKind = DynamicDataSourceTypeAutoDetect; + + foreach (TypedConstant arg in attribute.ConstructorArguments) + { + if (arg.Type?.SpecialType == SpecialType.System_String) + { + memberName = arg.Value?.ToString(); + } + else if (arg.Value is INamedTypeSymbol argTypeSymbol) + { + memberTypeSymbol = argTypeSymbol; + } + else if (arg.Value is int argValueAsInt) + { + memberKind = argValueAsInt; + } + } + + return memberName is null + ? null + : TryBuildFromDynamicData(memberTypeSymbol, memberName, ToSymbolKind(memberKind), wellKnownTypes, methodSymbol); + + static SymbolKind? ToSymbolKind(int memberKind) => + memberKind switch + { + DynamicDataSourceTypeProperty => SymbolKind.Property, + DynamicDataSourceTypeMethod => SymbolKind.Method, + DynamicDataSourceTypeAutoDetect => null, + _ => throw ApplicationStateGuard.Unreachable(), + }; + } + + private static DynamicDataTestMethodArgumentsInfo? TryBuildFromDynamicData(INamedTypeSymbol memberTypeSymbol, string memberName, SymbolKind? symbolKind, + WellKnownTypes wellKnownTypes, IMethodSymbol testMethodSymbol) + { + // Dynamic data only support Properties and Methods, but not fields. + // null is also possible and means "AutoDetect" + if (symbolKind is not (SymbolKind.Property or SymbolKind.Method or null)) + { + return null; + } + + ISymbol? firstMatchingMember = memberTypeSymbol.GetAllMembers(memberName) + .SelectMany(x => x) + // DynamicData does not have option to not specify the kind of member we are looking for at the moment. + // But the code below can easily handle searching for all kinds of supported members, and only + // take the kind into consideration when needed. If a ctor is added to DynamicData, or if we can tell if + // user specified the value explicitly or if it was taken from the default value, then `symbolKind` can be made nullable + // and only filtered below. + // .Where(s => s.IsStatic && (s.Kind is SymbolKind.Field or SymbolKind.Property or SymbolKind.Method)) + // .Where(s => symbolKind == null || s.Kind == symbolKind) + .Where(s => s.IsStatic && (s.Kind == symbolKind || (symbolKind is null && s.Kind is SymbolKind.Property or SymbolKind.Method))) + .Select(s => s switch + { + IPropertySymbol propertySymbol => (ISymbol)propertySymbol, + IMethodSymbol methodSymbol => methodSymbol, + _ => throw ApplicationStateGuard.Unreachable(), + }) + .OrderBy(tuple => tuple.Kind switch + { + SymbolKind.Property => 1, + SymbolKind.Method => 2, + _ => throw ApplicationStateGuard.Unreachable(), + }) + .FirstOrDefault(); + + if (firstMatchingMember is null + || firstMatchingMember.GetMemberType() is not { } returnMemberTypeSymbol) + { + return null; + } + + // We want to check if the member returns a type that implements IEnumerable> + var allInterfacesAndSelfIfInterface = new List(returnMemberTypeSymbol.AllInterfaces); + if (returnMemberTypeSymbol.TypeKind == TypeKind.Interface + && returnMemberTypeSymbol is INamedTypeSymbol namedInterfaceSymbol) + { + allInterfacesAndSelfIfInterface.Add(namedInterfaceSymbol); + } + + // If the return type is not IEnumerable> we will adapt it later. + // The implementation of https://github.com/microsoft/testfx/blob/main/src/TestFramework/TestFramework/Attributes/DataSource/DynamicDataAttribute.cs#L87 + // only allows IEnumerable so we will assume that is true. + bool targetMemberReturnsCollectionOfTestArgumentsEntry = allInterfacesAndSelfIfInterface.Any(i => + SymbolEqualityComparer.Default.Equals(i.OriginalDefinition, wellKnownTypes.IEnumerable1Symbol) + && i.TypeArguments.Length == 1 + && SymbolEqualityComparer.Default.Equals(i.TypeArguments[0].OriginalDefinition, wellKnownTypes.TestArgumentsEntrySymbol)); + + string? generatorMethodFullName = null; + + // This way we could handle the additional named parameters on [DynamicData + // DynamicDataDisplayName + // and DynamicDataDisplayNameDeclaringType, but this is imperfect implementation for now. + // KeyValuePair argumentsEntryProviderMethodType = namedArguments.FirstOrDefault(x => x.Key == TestArgumentsEntryProviderMethodType); + // if (argumentsEntryProviderMethodType.Key == TestArgumentsEntryProviderMethodType + // && argumentsEntryProviderMethodType.Value.Value is INamedTypeSymbol generatorMethodTypeSymbol) + // { + // generatorMethodFullName = generatorMethodTypeSymbol.ToDisplayString(); + // } + // + // KeyValuePair argumentsEntryProviderMethodName = namedArguments.FirstOrDefault(x => x.Key == TestArgumentsEntryProviderMethodName); + // if (argumentsEntryProviderMethodName.Key == TestArgumentsEntryProviderMethodName + // && argumentsEntryProviderMethodName.Value.Value is string generatorMethodName) + // { + // generatorMethodFullName ??= methodTypeSymbol.ToDisplayString(); + // generatorMethodFullName = $"{generatorMethodFullName}.{generatorMethodName}"; + // } + var testMethodParameters = new TestMethodParametersInfo(testMethodSymbol.Parameters); + + return new(firstMatchingMember.Name, firstMatchingMember.ContainingType.ToDisplayString(), + firstMatchingMember.Kind, testMethodParameters, targetMemberReturnsCollectionOfTestArgumentsEntry, generatorMethodFullName); + } + + public void AppendArguments(IndentedStringBuilder nodeBuilder) + { + nodeBuilder.Append("GetArguments = static () => "); + + using (nodeBuilder.AppendBlock()) + { + if (_targetMethodReturnsCollectionOfTestArgumentsEntry) + { + // We just return the data as is. + nodeBuilder.Append(" return "); + } + else + { + // We need to convert the data to TestArgumentsEntry. + nodeBuilder.Append("var data = "); + } + + // Call the member. + nodeBuilder.Append($"{_memberFullType}.{_memberName}"); + + if (_memberKind is SymbolKind.Method) + { + nodeBuilder.Append("()"); + } + + nodeBuilder.AppendLine(";"); + + if (!_targetMethodReturnsCollectionOfTestArgumentsEntry) + { + string tupleType = $"{TestArgumentsEntryTypeName}<{_testMethodParameters.ParametersTuple}>"; + nodeBuilder.AppendLine($"var dataCollection = new ColGen.List<{tupleType}>();"); + nodeBuilder.AppendLine($"var index = 0;"); + string expand = string.Join(", ", _testMethodParameters.Parameters.Select((p, i) => $"({p.FullyQualifiedType}) item[{i}]")); + using (nodeBuilder.AppendBlock("foreach (var item in data)")) + { + IEnumerable parameterNames = _testMethodParameters.Parameters.Select(p => p.Name); + nodeBuilder.AppendLine($$"""string uidFragment = {{DynamicDataNameProviderTypeName}}.GetUidFragment(new string[] {"{{string.Join("\", \"", parameterNames)}}"}, item, index);"""); + nodeBuilder.AppendLine("index++;"); + nodeBuilder.AppendLine($"""dataCollection.Add(new(({expand}), uidFragment));"""); + } + + nodeBuilder.AppendLine("return dataCollection;"); + } + } + + nodeBuilder.AppendLine(","); + } +} diff --git a/src/Analyzers/MSTest.SourceGeneration/ObjectModels/IParameterInfo.cs b/src/Analyzers/MSTest.SourceGeneration/ObjectModels/IParameterInfo.cs new file mode 100644 index 0000000000..470c8723b2 --- /dev/null +++ b/src/Analyzers/MSTest.SourceGeneration/ObjectModels/IParameterInfo.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +namespace Microsoft.Testing.Framework.SourceGeneration.ObjectModels; + +internal interface IParameterInfo +{ + string Name { get; } +} diff --git a/src/Analyzers/MSTest.SourceGeneration/ObjectModels/ITestMethodArgumentsInfo.cs b/src/Analyzers/MSTest.SourceGeneration/ObjectModels/ITestMethodArgumentsInfo.cs new file mode 100644 index 0000000000..c7e19cb416 --- /dev/null +++ b/src/Analyzers/MSTest.SourceGeneration/ObjectModels/ITestMethodArgumentsInfo.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +using Microsoft.Testing.Framework.SourceGeneration.Helpers; + +namespace Microsoft.Testing.Framework.SourceGeneration.ObjectModels; + +internal interface ITestMethodArgumentsInfo +{ + bool IsTestArgumentsEntryReturnType { get; } + + string? GeneratorMethodFullName { get; } + + void AppendArguments(IndentedStringBuilder nodeBuilder); +} diff --git a/src/Analyzers/MSTest.SourceGeneration/ObjectModels/TestMethodInfo.cs b/src/Analyzers/MSTest.SourceGeneration/ObjectModels/TestMethodInfo.cs new file mode 100644 index 0000000000..ec9d1a97c1 --- /dev/null +++ b/src/Analyzers/MSTest.SourceGeneration/ObjectModels/TestMethodInfo.cs @@ -0,0 +1,338 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +using System.Collections.Immutable; + +using Microsoft.CodeAnalysis; +using Microsoft.Testing.Framework.SourceGeneration.Helpers; + +namespace Microsoft.Testing.Framework.SourceGeneration.ObjectModels; + +internal sealed class TestMethodInfo +{ + internal const string TestArgumentsEntryTypeName = "MSTF::InternalUnsafeTestArgumentsEntry"; + private const string CtorVariableName = "instance"; + private const string TestExecutionContextVariableName = "testExecutionContext"; + private const string DataVariableName = "data"; + private const string DataDotArgumentsMemberAccessName = DataVariableName + ".Arguments"; + private readonly ImmutableArray<(string FilePath, int StartLine, int EndLine)> _declarationReferences; + private readonly string _methodName; + private readonly string _declaringAssemblyName; + private readonly string _usingTypeFullyQualifiedName; + private readonly bool _isAsync; + private readonly ImmutableArray<(string Key, string? Value)> _testProperties; + private readonly TimeSpan? _testExecutionTimeout; + private readonly ImmutableArray<(string RuleId, string Description)> _invocationPragmas; + private readonly string _methodIdentifierAssemblyName; + private readonly string _methodIdentifierNamespace; + private readonly string _methodIdentifierTypeName; + private readonly string _methodIdentifierReturnFullyQualifiedTypeName; + + private TestMethodInfo(IMethodSymbol methodSymbol, INamedTypeSymbol typeUsingMethod, ImmutableArray<(string Key, string? Value)> testProperties, + TestMethodParametersInfo parametersInfo, ITestMethodArgumentsInfo? argumentsInfo, + IEnumerable<(string RuleId, string Description)> invocationPragmas, TimeSpan? testExecutionTimeout) + { + // 'SymbolDisplayFormat.CSharpShortErrorMessageFormat' gives us the minimal name while preserving sub-classes + _methodIdentifierTypeName = methodSymbol.ContainingType.ToDisplayString(SymbolDisplayFormat.CSharpShortErrorMessageFormat); + _methodIdentifierNamespace = methodSymbol.ContainingNamespace.IsGlobalNamespace + ? string.Empty + : methodSymbol.ContainingNamespace.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat); + _methodIdentifierReturnFullyQualifiedTypeName = methodSymbol.ReturnType.ToDisplayString(TestMethods.MethodIdentifierFullyQualifiedTypeFormat); + ArgumentsInfo = argumentsInfo; + _testExecutionTimeout = testExecutionTimeout; + _invocationPragmas = invocationPragmas.ToImmutableArray(); + _usingTypeFullyQualifiedName = typeUsingMethod.ToDisplayString(); + // NOTE: the method symbol containing type is the type declaring the method, not the type using the method. + string fullyQualifiedDisplayName = _usingTypeFullyQualifiedName + + "." + + methodSymbol.ToDisplayString().Substring(methodSymbol.ContainingType.ToDisplayString().Length + 1); + _declarationReferences = methodSymbol.DeclaringSyntaxReferences + .Select(x => (x.SyntaxTree.FilePath, x.SyntaxTree.GetLineSpan(x.Span))) + .Select(tuple => (tuple.FilePath, tuple.Item2.StartLinePosition.Line + 1, tuple.Item2.EndLinePosition.Line + 1)) + .ToImmutableArray(); + _methodName = methodSymbol.Name; + // 'SymbolDisplayFormat.FullyQualifiedFormat' would add version, culture and public key token to the assembly name. + _declaringAssemblyName = methodSymbol.ContainingAssembly.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat); + _methodIdentifierAssemblyName = methodSymbol.ContainingAssembly.ToDisplayString(); + _isAsync = !methodSymbol.ReturnsVoid; + _testProperties = testProperties; + ParametersInfo = parametersInfo; + + TestMethodStableUid = $"\"{_declaringAssemblyName}.{fullyQualifiedDisplayName}\""; + } + + internal string TestMethodStableUid { get; } + + internal ITestMethodArgumentsInfo? ArgumentsInfo { get; } + + internal TestMethodParametersInfo ParametersInfo { get; } + + public static TestMethodInfo? TryBuild(IMethodSymbol methodSymbol, INamedTypeSymbol typeUsingMethod, WellKnownTypes wellKnownTypes) + { + try + { + // We don't need to be checking for resultant visibility here because we know parent is checking for it + if (!methodSymbol.IsValidTestMethodShape(wellKnownTypes) + || methodSymbol.IsKnownNonTestMethod(wellKnownTypes)) + { + return null; + } + + ImmutableArray attributes = methodSymbol.GetAttributes(); + + if (attributes.Length == 0 || !attributes.Any(a => SymbolEqualityComparer.Default.Equals(a.AttributeClass, wellKnownTypes.TestMethodAttributeSymbol))) + { + return null; + } + + List dataRowAttributes = new(); + List dynamicDataAttributes = new(); + List testPropertyAttributes = new(); + List<(string RuleId, string Description)> pragmas = new(); + TimeSpan? testExecutionTimeout = null; + foreach (AttributeData attribute in attributes) + { + if (SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, wellKnownTypes.DataRowAttributeSymbol) + && attribute.ConstructorArguments.Length == 1) + { + dataRowAttributes.Add(attribute); + } + else if (SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, wellKnownTypes.DynamicDataAttributeSymbol) + && attribute.ConstructorArguments.Length is 1 or 2 or 3) + { + dynamicDataAttributes.Add(attribute); + } + else if (SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, wellKnownTypes.TestPropertyAttributeSymbol) + && attribute.ConstructorArguments.Length == 2) + { + testPropertyAttributes.Add(attribute); + } + else if (SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, wellKnownTypes.SystemObsoleteAttributeSymbol)) + { + if (attribute.ConstructorArguments.Length == 0) + { + pragmas.Add(("CS0612", "Type or member is obsolete")); + } + else if (attribute.ConstructorArguments.Length == 1 + // We cannot suppress CS0619 as it's an error level + || (attribute.ConstructorArguments.Length == 2 && attribute.ConstructorArguments[1].Value?.Equals(false) == true)) + { + pragmas.Add(("CS0618", "Type or member is obsolete")); + } + } + else if (attribute.TryGetTestExecutionTimeout(wellKnownTypes.TestExecutionTimeoutAttributeSymbol, wellKnownTypes.TimeSpanSymbol, + out TimeSpan maybeTestExecutionTimeout)) + { + testExecutionTimeout = maybeTestExecutionTimeout; + } + } + + TestMethodParametersInfo parametersInfo = new(methodSymbol.Parameters); + + // TODO: This code is not handling the case where both DataRow and DynamicData attributes are present. + ITestMethodArgumentsInfo? argumentsInfo = + (ITestMethodArgumentsInfo?)DataRowTestMethodArgumentsInfo.TryBuild(methodSymbol, dataRowAttributes, parametersInfo) + ?? DynamicDataTestMethodArgumentsInfo.TryBuild(methodSymbol, dynamicDataAttributes, wellKnownTypes); + + ImmutableArray<(string Key, string? Value)> testProperties = testPropertyAttributes + .Where(attr => attr.ConstructorArguments[0].Value is not null) + .Select(attr => (attr.ConstructorArguments[0].Value!.ToString()!, attr.ConstructorArguments[1].Value?.ToString())) + .ToImmutableArray(); + + // Method is valid test method + return new(methodSymbol, typeUsingMethod, testProperties, parametersInfo, argumentsInfo, pragmas, testExecutionTimeout); + } + catch (Exception ex) + { + throw new InvalidOperationException($"Failed for method {methodSymbol.ToDisplayString()}, with {ex}", ex); + } + } + + public void AppendTestNode(IndentedStringBuilder sourceStringBuilder, TestTypeInfo testTypeInfo) + { + bool useAsyncNode = _isAsync; + AppendTestNodeCtorDeclaration(sourceStringBuilder, useAsyncNode, ParametersInfo.ParametersTuple, ArgumentsInfo); + using (sourceStringBuilder.AppendBlock(closingBraceSuffixChar: ',')) + { + sourceStringBuilder.AppendLine($"StableUid = {TestMethodStableUid},"); + sourceStringBuilder.AppendLine($"DisplayName = \"{_methodName}\","); + + int propertiesCount = + 1 // properties that are always present + + _declarationReferences.Length + + _testProperties.Length; + using (sourceStringBuilder.AppendBlock($"Properties = new Msg::IProperty[{propertiesCount}]", closingBraceSuffixChar: ',')) + { + sourceStringBuilder.AppendLine("new Msg::TestMethodIdentifierProperty("); + sourceStringBuilder.IndentationLevel++; + sourceStringBuilder.AppendLine($"\"{_methodIdentifierAssemblyName}\","); + sourceStringBuilder.AppendLine($"\"{_methodIdentifierNamespace}\","); + sourceStringBuilder.AppendLine($"\"{_methodIdentifierTypeName}\","); + sourceStringBuilder.AppendLine($"\"{_methodName}\","); + + if (ParametersInfo.ParametersMethodIdentifierFullyQualifiedTypes.Length > 0) + { + using (sourceStringBuilder.AppendBlock($"new string[{ParametersInfo.ParametersMethodIdentifierFullyQualifiedTypes.Length}]", closingBraceSuffixChar: ',')) + { + foreach (string parameterIdentifierType in ParametersInfo.ParametersMethodIdentifierFullyQualifiedTypes) + { + sourceStringBuilder.AppendLine($"\"{parameterIdentifierType}\","); + } + } + } + else + { + sourceStringBuilder.AppendLine("Sys::Array.Empty(),"); + } + + sourceStringBuilder.AppendLine($"\"{_methodIdentifierReturnFullyQualifiedTypeName}\"),"); + sourceStringBuilder.IndentationLevel--; + + foreach ((string filePath, int startLine, int endLine) in _declarationReferences) + { + sourceStringBuilder.AppendLine($"new Msg::TestFileLocationProperty(@\"{filePath}\", new(new({startLine}, -1), new({endLine}, -1))),"); + } + + foreach ((string key, string? value) in _testProperties) + { + sourceStringBuilder.AppendLine($"new Msg::TestMetadataProperty(\"{key}\", \"{value}\"),"); + } + } + + ArgumentsInfo?.AppendArguments(sourceStringBuilder); + sourceStringBuilder.Append("Body = static "); + if (useAsyncNode) + { + sourceStringBuilder.Append("async "); + } + + sourceStringBuilder.Append( + ParametersInfo.Parameters.IsEmpty + ? TestExecutionContextVariableName + : $"({TestExecutionContextVariableName}, {DataVariableName})"); + + using (sourceStringBuilder.AppendBlock(" =>", closingBraceSuffixChar: ',')) + { + MaybeAppendBodyCancellationTokenCreation(sourceStringBuilder, testTypeInfo); + AppendCtorCall(sourceStringBuilder, testTypeInfo); + AppendMethodCall(sourceStringBuilder); + } + } + } + + private void MaybeAppendBodyCancellationTokenCreation(IndentedStringBuilder sourceStringBuilder, TestTypeInfo testTypeInfo) + { + if (testTypeInfo.TestExecutionTimeout is null && _testExecutionTimeout is null) + { + return; + } + + TimeSpan minTimeout = (testTypeInfo.TestExecutionTimeout, _testExecutionTimeout) switch + { + (null, { } time) => time, + ({ } time, null) => time, + ({ } time1, { } time2) when time1 <= time2 => time1, + ({ } time1, { } time2) when time1 > time2 => time2, + _ => throw ApplicationStateGuard.Unreachable(), + }; + + sourceStringBuilder.AppendLine($"{TestExecutionContextVariableName}.CancelTestExecution(new global::System.TimeSpan({minTimeout.Ticks}));"); + } + + private static void AppendTestNodeCtorDeclaration(IndentedStringBuilder nodeBuilder, bool useAsyncNode, + string? parametersTuple, ITestMethodArgumentsInfo? argumentsInfo) + { + if (parametersTuple != null && argumentsInfo is null) + { + nodeBuilder.AppendLine("// The test method is parameterized but no argument was specified."); + nodeBuilder.AppendLine("// This is most often caused by using an unsupported arguments input."); + nodeBuilder.AppendLine("// Possible resolutions:"); + nodeBuilder.AppendLine("// - There is a mismatch between arguments from [DataRow] and the method parameters."); + nodeBuilder.AppendLine("// - There is a mismatch between arguments from [DynamicData] and the method parameters."); + nodeBuilder.AppendLine("// If nothing else worked, report the error and exclude this method by using [Ignore]."); + } + + nodeBuilder.Append("new MSTF::"); + nodeBuilder.Append((useAsyncNode, argumentsInfo) switch + { + (true, null) => "InternalUnsafeAsyncActionTestNode", + (true, _) => "InternalUnsafeAsyncActionParameterizedTestNode", + + (false, null) => "InternalUnsafeActionTestNode", + (false, _) => "InternalUnsafeActionParameterizedTestNode", + }); + nodeBuilder.AppendLine((parametersTuple, argumentsInfo?.IsTestArgumentsEntryReturnType ?? false) switch + { + (null, _) => string.Empty, + (_, false) => $"<{parametersTuple}>", + (_, true) => $"<{TestArgumentsEntryTypeName}<{parametersTuple}>>", + }); + } + + private static void AppendCtorCall(IndentedStringBuilder nodeBuilder, TestTypeInfo testTypeInfo) + { + if (testTypeInfo.IsIAsyncDisposable) + { + nodeBuilder.Append("await using "); + } + else if (testTypeInfo.IsIDisposable) + { + nodeBuilder.Append("using "); + } + + nodeBuilder.Append($"var {CtorVariableName} = new {testTypeInfo.ConstructorShortName}();"); + } + + private void AppendMethodCall(IndentedStringBuilder sourceStringBuilder) + { + sourceStringBuilder.AppendLine(); + IDisposable tryBlock = sourceStringBuilder.AppendBlock("try"); + + foreach ((string ruleId, string description) in _invocationPragmas) + { + sourceStringBuilder.AppendUnindentedLine($"#pragma warning disable {ruleId} // {description}"); + } + + if (_isAsync) + { + sourceStringBuilder.Append("await "); + } + + sourceStringBuilder.Append($"{CtorVariableName}.{_methodName}("); + + string dataVariable = ArgumentsInfo?.IsTestArgumentsEntryReturnType ?? false + ? DataDotArgumentsMemberAccessName + : DataVariableName; + + if (ParametersInfo.Parameters.Length == 1) + { + sourceStringBuilder.Append(dataVariable); + } + else + { + for (int i = 0; i < ParametersInfo.Parameters.Length; i++) + { + if (i > 0) + { + sourceStringBuilder.Append(", "); + } + + sourceStringBuilder.Append($"{dataVariable}.{ParametersInfo.Parameters[i].Name}"); + } + } + + sourceStringBuilder.AppendLine(");"); + + foreach ((string ruleId, string description) in _invocationPragmas) + { + sourceStringBuilder.AppendUnindentedLine($"#pragma warning restore {ruleId} // {description}"); + } + + tryBlock?.Dispose(); + + using (sourceStringBuilder.AppendBlock("catch (global::System.Exception ex)")) + { + sourceStringBuilder.AppendLine($"{TestExecutionContextVariableName}.ReportException(ex, null);"); + } + } +} diff --git a/src/Analyzers/MSTest.SourceGeneration/ObjectModels/TestMethodParametersInfo.cs b/src/Analyzers/MSTest.SourceGeneration/ObjectModels/TestMethodParametersInfo.cs new file mode 100644 index 0000000000..e92b79e650 --- /dev/null +++ b/src/Analyzers/MSTest.SourceGeneration/ObjectModels/TestMethodParametersInfo.cs @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +using System.Collections.Immutable; + +using Microsoft.CodeAnalysis; + +namespace Microsoft.Testing.Framework.SourceGeneration.ObjectModels; + +internal sealed class TestMethodParametersInfo +{ + public TestMethodParametersInfo(ImmutableArray parameters) + { + Parameters = parameters + .Select(p => (p.Name, p.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat))) + .ToImmutableArray(); + ParametersTuple = BuildParametersTupleString(Parameters); + ParametersMethodIdentifierFullyQualifiedTypes = parameters.Select(p => p.Type.ToDisplayString(TestMethods.MethodIdentifierFullyQualifiedTypeFormat)).ToImmutableArray(); + } + + public ImmutableArray<(string Name, string FullyQualifiedType)> Parameters { get; } + + public string? ParametersTuple { get; } + + public ImmutableArray ParametersMethodIdentifierFullyQualifiedTypes { get; } + + private static string? BuildParametersTupleString(ImmutableArray<(string Name, string FullyQualifiedType)> parameters) + { + if (parameters.IsDefaultOrEmpty) + { + return null; + } + + if (parameters.Length == 1) + { + return parameters[0].FullyQualifiedType; + } + + var tupleTypeStringBuilder = new StringBuilder("("); + + for (int i = 0; i < parameters.Length; i++) + { + if (i > 0) + { + tupleTypeStringBuilder.Append(", "); + } + + tupleTypeStringBuilder.Append(parameters[i].FullyQualifiedType).Append(' ').Append(parameters[i].Name); + } + + return tupleTypeStringBuilder.Append(')').ToString(); + } +} diff --git a/src/Analyzers/MSTest.SourceGeneration/ObjectModels/TestNamespaceInfo.cs b/src/Analyzers/MSTest.SourceGeneration/ObjectModels/TestNamespaceInfo.cs new file mode 100644 index 0000000000..a17a31fc11 --- /dev/null +++ b/src/Analyzers/MSTest.SourceGeneration/ObjectModels/TestNamespaceInfo.cs @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +using Microsoft.CodeAnalysis; +using Microsoft.Testing.Framework.SourceGeneration.Helpers; + +namespace Microsoft.Testing.Framework.SourceGeneration.ObjectModels; + +internal sealed class TestNamespaceInfo : IEquatable +{ + private readonly string _nameOrGlobalNamespace; + private readonly string _containingAssembly; + + public string Name { get; } + + public string FullyQualifiedName { get; } + + public bool IsGlobalNamespace { get; } + + public TestNamespaceInfo(INamespaceSymbol namespaceSymbol) + { + _containingAssembly = namespaceSymbol.ContainingAssembly.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat); + Name = namespaceSymbol.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat); + _nameOrGlobalNamespace = namespaceSymbol.ToDisplayString(); + IsGlobalNamespace = namespaceSymbol.IsGlobalNamespace; + FullyQualifiedName = namespaceSymbol.IsGlobalNamespace + ? string.Empty + : namespaceSymbol.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat); + } + + public void AppendNamespaceTestNode(IndentedStringBuilder nodeStringBuilder, string testsVariableName) + { + using (nodeStringBuilder.AppendTestNode(_containingAssembly + "." + _nameOrGlobalNamespace, _nameOrGlobalNamespace, + Array.Empty(), testsVariableName)) + { + } + } + + public bool Equals(TestNamespaceInfo? other) + => other is not null + && other.FullyQualifiedName == FullyQualifiedName; + + public override bool Equals(object? obj) + => Equals(obj as TestNamespaceInfo); + + public override int GetHashCode() + => FullyQualifiedName.GetHashCode(); +} diff --git a/src/Analyzers/MSTest.SourceGeneration/ObjectModels/TestTypeInfo.cs b/src/Analyzers/MSTest.SourceGeneration/ObjectModels/TestTypeInfo.cs new file mode 100644 index 0000000000..00b5de7133 --- /dev/null +++ b/src/Analyzers/MSTest.SourceGeneration/ObjectModels/TestTypeInfo.cs @@ -0,0 +1,171 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. + +using System.Collections.Immutable; + +using Analyzers.Utilities; + +using Microsoft.CodeAnalysis; +using Microsoft.Testing.Framework.SourceGeneration.Helpers; + +namespace Microsoft.Testing.Framework.SourceGeneration.ObjectModels; + +internal sealed class TestTypeInfo +{ + private readonly string _name; + private readonly string _containingAssemblyName; + private readonly ImmutableArray<(string FilePath, int StartLine, int EndLine)> _declarationReferences; + + internal ImmutableArray TestMethodNodes { get; } + + public TimeSpan? TestExecutionTimeout { get; } + + internal string GeneratedTypeName { get; } + + internal string FullyQualifiedName { get; } + + internal TestNamespaceInfo ContainingNamespace { get; } + + internal bool IsIAsyncDisposable { get; } + + internal bool IsIDisposable { get; } + + internal string ConstructorShortName { get; } + + private TestTypeInfo(INamedTypeSymbol namedTypeSymbol, IMethodSymbol ctorToUse, + WellKnownTypes wellKnownTypes, ImmutableArray testMethodNodes, TimeSpan? testExecutionTimeout) + { + _name = namedTypeSymbol.Name; + _declarationReferences = namedTypeSymbol.DeclaringSyntaxReferences + .Select(x => (x.SyntaxTree.FilePath, x.SyntaxTree.GetLineSpan(x.Span))) + .Select(tuple => (tuple.FilePath, tuple.Item2.StartLinePosition.Line + 1, tuple.Item2.EndLinePosition.Line + 1)) + .ToImmutableArray(); + TestMethodNodes = testMethodNodes; + TestExecutionTimeout = testExecutionTimeout; + _containingAssemblyName = namedTypeSymbol.ContainingAssembly.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat); + ContainingNamespace = new(namedTypeSymbol.ContainingNamespace); + FullyQualifiedName = namedTypeSymbol.ToDisplayString(); + string escapedFullyQualifiedName = TestNodeHelpers.GenerateEscapedName(FullyQualifiedName); + GeneratedTypeName = ContainingNamespace.IsGlobalNamespace + ? "_" + escapedFullyQualifiedName + : escapedFullyQualifiedName; + + // 'SymbolDisplayFormat.CSharpShortErrorMessageFormat' gives us the minimal name while preserving sub-classes + ConstructorShortName = ctorToUse.ContainingType.ToDisplayString(SymbolDisplayFormat.CSharpShortErrorMessageFormat); + + IsIAsyncDisposable = namedTypeSymbol.AllInterfaces.Any(i => + SymbolEqualityComparer.Default.Equals(i.OriginalDefinition, wellKnownTypes.IAsyncDisposableSymbol)); + IsIDisposable = namedTypeSymbol.AllInterfaces.Any(i => + SymbolEqualityComparer.Default.Equals(i.OriginalDefinition, wellKnownTypes.IDisposableSymbol)); + } + + public static TestTypeInfo? TryBuild(GeneratorAttributeSyntaxContext context, WellKnownTypes wellKnownTypes) + { + if (context.TargetSymbol is not INamedTypeSymbol namedTypeSymbol) + { + return null; + } + + // The generator syntax checks should have already filtered out any types that are not public/internal but we still need + // to check because a public subclass of a non-public class is still not public. + if (namedTypeSymbol.GetResultantVisibility() is not SymbolVisibility.Public and not SymbolVisibility.Internal) + { + return null; + } + + // We only support simple classes + if (namedTypeSymbol.IsAbstract + || namedTypeSymbol.IsAnonymousType + || namedTypeSymbol.IsGenericType + || namedTypeSymbol.IsImplicitClass) + { + return null; + } + + if (!HasParameterlessConstructor(namedTypeSymbol, out IMethodSymbol? parameterlessCtor)) + { + return null; + } + + TimeSpan? testExecutionTimeout = null; + foreach (AttributeData attribute in namedTypeSymbol.GetAttributes()) + { + if (attribute.TryGetTestExecutionTimeout(wellKnownTypes.TestExecutionTimeoutAttributeSymbol, wellKnownTypes.TimeSpanSymbol, + out TimeSpan maybeExecutionTimeout)) + { + testExecutionTimeout = maybeExecutionTimeout; + } + } + + var testMethodNodes = namedTypeSymbol + .GetAllMembers() + .SelectMany(members => members) + .OfType() + .Select(method => TestMethodInfo.TryBuild(method, namedTypeSymbol, wellKnownTypes)) + .WhereNotNull() + .ToImmutableArray(); + + return new(namedTypeSymbol, parameterlessCtor, wellKnownTypes, testMethodNodes, testExecutionTimeout); + } + + private static bool HasParameterlessConstructor(INamedTypeSymbol namedTypeSymbol, [NotNullWhen(true)] out IMethodSymbol? parameterlessConstructor) + { + parameterlessConstructor = namedTypeSymbol.InstanceConstructors + .FirstOrDefault(ctor => ctor.DeclaredAccessibility == Accessibility.Public && ctor.Parameters.Length == 0); + + return parameterlessConstructor != null; + } + + public void AppendTestNode(IndentedStringBuilder sourceStringBuilder) + { + IDisposable? block = null; + + try + { + if (!ContainingNamespace.IsGlobalNamespace) + { + sourceStringBuilder.Append("namespace "); + // TODO: Understand how to retrieve assembly default namespace and use it instead of assembly name + block = sourceStringBuilder.AppendBlock(ContainingNamespace.FullyQualifiedName); + } + + sourceStringBuilder.AppendLine("using Threading = global::System.Threading;"); + sourceStringBuilder.AppendLine("using ColGen = global::System.Collections.Generic;"); + sourceStringBuilder.AppendLine("using CA = global::System.Diagnostics.CodeAnalysis;"); + sourceStringBuilder.AppendLine("using Sys = global::System;"); + + sourceStringBuilder.AppendLine("using Msg = global::Microsoft.Testing.Platform.Extensions.Messages;"); + sourceStringBuilder.AppendLine("using MSTF = global::Microsoft.Testing.Framework;"); + + sourceStringBuilder.AppendLine(); + + sourceStringBuilder.AppendLine("[CA::ExcludeFromCodeCoverage]"); + using (sourceStringBuilder.AppendBlock($"public static class {GeneratedTypeName}")) + { + sourceStringBuilder.Append("public static readonly MSTF::TestNode TestNode = "); + AppendTestNodeCreation(sourceStringBuilder); + } + } + finally + { + block?.Dispose(); + } + } + + private void AppendTestNodeCreation(IndentedStringBuilder sourceStringBuilder) + { + List properties = new(); + foreach ((string filePath, int startLine, int endLine) in _declarationReferences) + { + properties.Add($"new Msg::TestFileLocationProperty(@\"{filePath}\", new(new({startLine}, -1), new({endLine}, -1))),"); + } + + using (sourceStringBuilder.AppendTestNode(_containingAssemblyName + "." + FullyQualifiedName, _name, properties, ';')) + { + foreach (TestMethodInfo testMethod in TestMethodNodes) + { + testMethod.AppendTestNode(sourceStringBuilder, this); + } + } + } +} diff --git a/src/Analyzers/MSTest.SourceGeneration/PACKAGE.md b/src/Analyzers/MSTest.SourceGeneration/PACKAGE.md new file mode 100644 index 0000000000..cdd8f5ba18 --- /dev/null +++ b/src/Analyzers/MSTest.SourceGeneration/PACKAGE.md @@ -0,0 +1,9 @@ +# Microsoft.Testing + +Microsoft Testing is a set of platform, framework and protocol intended to make it possible to run any test on any target or device. + +Documentation can be found at . + +## About + +This package works with MSTest.Engine package to provide source generators. diff --git a/test/IntegrationTests/MSTest.Acceptance.IntegrationTests/MSTest.Acceptance.IntegrationTests.csproj b/test/IntegrationTests/MSTest.Acceptance.IntegrationTests/MSTest.Acceptance.IntegrationTests.csproj index 037f3d9600..9d9e7b3cd9 100644 --- a/test/IntegrationTests/MSTest.Acceptance.IntegrationTests/MSTest.Acceptance.IntegrationTests.csproj +++ b/test/IntegrationTests/MSTest.Acceptance.IntegrationTests/MSTest.Acceptance.IntegrationTests.csproj @@ -3,7 +3,7 @@ $(NetCurrent) false - true + true $(DefineConstants);SKIP_INTERMEDIATE_TARGET_FRAMEWORKS Exe true diff --git a/test/UnitTests/MSTest.Engine.UnitTests/Adapter_ExecuteRequestAsyncTests.cs b/test/UnitTests/MSTest.Engine.UnitTests/Adapter_ExecuteRequestAsyncTests.cs new file mode 100644 index 0000000000..01a6870fcb --- /dev/null +++ b/test/UnitTests/MSTest.Engine.UnitTests/Adapter_ExecuteRequestAsyncTests.cs @@ -0,0 +1,143 @@ +// 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.Configurations; +using Microsoft.Testing.Platform.Extensions.Messages; +using Microsoft.Testing.Platform.Helpers; +using Microsoft.Testing.Platform.Logging; +using Microsoft.Testing.Platform.Messages; +using Microsoft.Testing.Platform.Requests; +using Microsoft.Testing.Platform.Services; +using Microsoft.Testing.Platform.TestHost; + +namespace Microsoft.Testing.Framework.UnitTests; + +[TestClass] +public class Adapter_ExecuteRequestAsyncTests : TestBase +{ + [TestMethod] + public async Task ExecutableNode_ThatDoesNotThrow_ShouldReportPassed() + { + // Arrange + var testNode = new InternalUnsafeActionTestNode + { + StableUid = "Microsoft.Testing.Framework.UnitTests.Adapter_ExecuteRequestAsyncTests.ExecutableNode_ThatDoesNotThrow_ShouldReportPassed()", + DisplayName = "Microsoft.Testing.Framework.UnitTests.Adapter_ExecuteRequestAsyncTests.ExecutableNode_ThatDoesNotThrow_ShouldReportPassed()", + Body = testExecutionContext => { }, + }; + + var services = new Services(); + var adapter = new TestFramework(new(), new[] { new FactoryTestNodesBuilder(() => new[] { testNode }) }, new(), + services.ServiceProvider.GetSystemClock(), services.ServiceProvider.GetTask(), services.ServiceProvider.GetConfiguration(), new Platform.Capabilities.TestFramework.TestFrameworkCapabilities()); + + CancellationToken cancellationToken = CancellationToken.None; + + // Act +#pragma warning disable TPEXP // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + await adapter.ExecuteRequestAsync(new( + new RunTestExecutionRequest(new(new("id"), new ClientInfo(string.Empty, string.Empty))), + services.ServiceProvider.GetRequiredService(), + new SemaphoreSlimRequestCompleteNotifier(new SemaphoreSlim(1)), + cancellationToken)); +#pragma warning restore TPEXP // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + + // Assert + IEnumerable nodeStateChanges = services.MessageBus.Messages.OfType(); + Assert.IsTrue(nodeStateChanges.Any(), $"{nameof(nodeStateChanges)} should have at least 1 item."); + Platform.Extensions.Messages.TestNode lastNode = nodeStateChanges.Last().TestNode; + _ = lastNode.Properties.Single(); + } + + [TestMethod] + public async Task ExecutableNode_ThatThrows_ShouldReportError() + { + // Arrange + var testNode = new InternalUnsafeActionTestNode + { + StableUid = "Microsoft.Testing.Framework.UnitTests.Adapter_ExecuteRequestAsyncTests.ExecutableNode_ThatThrows_ShouldReportError()", + DisplayName = "Microsoft.Testing.Framework.UnitTests.Adapter_ExecuteRequestAsyncTests.ExecutableNode_ThatThrows_ShouldReportError()", + Body = testExecutionContext => throw new InvalidOperationException("Oh no!") { }, + }; + + var services = new Services(); + var fakeClock = (FakeClock)services.ServiceProvider.GetService(typeof(FakeClock))!; + + var adapter = new TestFramework(new(), new[] { new FactoryTestNodesBuilder(() => new[] { testNode }) }, new(), + services.ServiceProvider.GetSystemClock(), services.ServiceProvider.GetTask(), services.ServiceProvider.GetConfiguration(), new Platform.Capabilities.TestFramework.TestFrameworkCapabilities()); + CancellationToken cancellationToken = CancellationToken.None; + + // Act +#pragma warning disable TPEXP // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + await adapter.ExecuteRequestAsync(new( + new RunTestExecutionRequest(new(new("id"), new ClientInfo(string.Empty, string.Empty))), + services.ServiceProvider.GetRequiredService(), + new SemaphoreSlimRequestCompleteNotifier(new SemaphoreSlim(1)), + cancellationToken)); +#pragma warning restore TPEXP // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + + // Assert + IEnumerable nodeStateChanges = services.MessageBus.Messages.OfType(); + Assert.IsTrue(nodeStateChanges.Any(), $"{nameof(nodeStateChanges)} should have at least 1 item."); + Platform.Extensions.Messages.TestNode lastNode = nodeStateChanges.Last().TestNode; + _ = lastNode.Properties.Single(); + Assert.AreEqual("Oh no!", lastNode.Properties.Single().Exception!.Message); + Assert.IsTrue( + lastNode.Properties.Single().Exception!.StackTrace! + .Contains(nameof(ExecutableNode_ThatThrows_ShouldReportError)), "lastNode properties should contain the name of the test"); + TimingProperty timingProperty = lastNode.Properties.Single(); + Assert.AreEqual(fakeClock.UsedTimes[0], timingProperty.GlobalTiming.StartTime); + Assert.IsTrue(timingProperty.GlobalTiming.StartTime <= timingProperty.GlobalTiming.EndTime, "start time is before (or the same as) stop time"); + Assert.AreEqual(fakeClock.UsedTimes[1], timingProperty.GlobalTiming.EndTime); + Assert.IsTrue(timingProperty.GlobalTiming.Duration.TotalMilliseconds > 0, $"duration should be greater than 0"); + } + + private sealed class FakeClock : IClock + { + public List UsedTimes { get; } = new(); + + public DateTimeOffset UtcNow + { + get + { + DateTimeOffset date = DateTimeOffset.UtcNow; + UsedTimes.Add(date); + return date; + } + } + } + + private sealed class Services + { + public Services() + { + MessageBus = new MessageBus(); + ServiceProvider.AddService(MessageBus); + ServiceProvider.AddService(new LoggerFactory()); + ServiceProvider.AddService(new FakeClock()); + ServiceProvider.AddService(new SystemTask()); +#pragma warning disable TPEXP // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + ServiceProvider.AddService(new AggregatedConfiguration(Array.Empty(), new CurrentTestApplicationModuleInfo(new SystemEnvironment(), new SystemProcessHandler()), new SystemFileSystem())); +#pragma warning restore TPEXP // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + } + + public MessageBus MessageBus { get; } + + public ServiceProvider ServiceProvider { get; } = new(); + } + + private sealed class MessageBus : IMessageBus + { + public List Messages { get; } = new(); + + public Task PublishAsync(IDataProducer dataProducer, IData data) + { + Messages.Add(data); + return Task.CompletedTask; + } + } + + private sealed class LoggerFactory : ILoggerFactory + { + public ILogger CreateLogger(string categoryName) => new NopLogger(); + } +} diff --git a/test/UnitTests/MSTest.Engine.UnitTests/BFSTestNodeVisitorTests.cs b/test/UnitTests/MSTest.Engine.UnitTests/BFSTestNodeVisitorTests.cs new file mode 100644 index 0000000000..c9c752dc78 --- /dev/null +++ b/test/UnitTests/MSTest.Engine.UnitTests/BFSTestNodeVisitorTests.cs @@ -0,0 +1,279 @@ +// 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.Extensions.Messages; +using Microsoft.Testing.Platform.Requests; + +#pragma warning disable TPEXP // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. +namespace Microsoft.Testing.Framework.UnitTests; + +[TestClass] +public sealed class BFSTestNodeVisitorTests : TestBase +{ + [TestMethod] + public async Task Visit_WhenFilterDoesNotUseEncodedSlash_NodeIsNotIncluded() + { + // Arrange + var rootNode = new TestNode + { + StableUid = "ID1", + DisplayName = "A", + Tests = new[] + { + new TestNode + { + StableUid = "ID2", + DisplayName = "B/C", + }, + }, + }; + + var filter = new TreeNodeFilter("/A/B/C"); + var visitor = new BFSTestNodeVisitor(new[] { rootNode }, filter, null!); + + // Act + List includedTestNodes = new(); + await visitor.VisitAsync((testNode, _) => + { + includedTestNodes.Add(testNode); + return Task.CompletedTask; + }); + + // Assert + Assert.AreEqual(1, includedTestNodes.Count); + Assert.AreEqual("ID1", includedTestNodes[0].StableUid); + } + + [DataRow("/", "%2F")] + [DataRow("%2F", "%252F")] + [DataRow("//", "%2F%2F")] + [TestMethod] + public async Task Visit_WhenFilterUsesEncodedEntry_NodeIsIncluded(string nodeSpecialString, string filterEncodedSpecialString) + { + // Arrange + var rootNode = new TestNode + { + StableUid = "ID1", + DisplayName = "A", + Tests = new[] + { + new TestNode + { + StableUid = "ID2", + DisplayName = "B" + nodeSpecialString + "C", + }, + }, + }; + + var filter = new TreeNodeFilter("/A/B" + filterEncodedSpecialString + "C"); + var visitor = new BFSTestNodeVisitor(new[] { rootNode }, filter, null!); + + // Act + List includedTestNodes = new(); + await visitor.VisitAsync((testNode, _) => + { + includedTestNodes.Add(testNode); + return Task.CompletedTask; + }); + + // Assert + Assert.AreEqual(2, includedTestNodes.Count); + Assert.AreEqual("ID1", includedTestNodes[0].StableUid); + Assert.AreEqual("ID2", includedTestNodes[1].StableUid); + } + + [DataRow(nameof(TestNode))] + [DataRow(nameof(InternalUnsafeActionTestNode))] + [TestMethod] + public async Task Visit_WhenNodeIsNotParameterizedNode_DoesNotExpand(string nonParameterizedTestNode) + { + // Arrange + TestNode rootNode = nonParameterizedTestNode switch + { + nameof(TestNode) => new TestNode + { + StableUid = "ID1", + DisplayName = "A", + }, + nameof(InternalUnsafeActionTestNode) => new InternalUnsafeActionTestNode + { + StableUid = "ID1", + DisplayName = "A", + Body = _ => { }, + }, + _ => throw new ArgumentException($"Unknown test node type: {nonParameterizedTestNode}", nameof(nonParameterizedTestNode)), + }; + + var visitor = new BFSTestNodeVisitor(new[] { rootNode }, new NopFilter(), null!); + + // Act + List includedTestNodes = new(); + await visitor.VisitAsync((testNode, _) => + { + includedTestNodes.Add(testNode); + return Task.CompletedTask; + }); + + // Assert + Assert.AreEqual(1, includedTestNodes.Count); + Assert.AreEqual("ID1", includedTestNodes[0].StableUid); + } + + [DataRow(nameof(InternalUnsafeActionParameterizedTestNode), true)] + [DataRow(nameof(InternalUnsafeActionParameterizedTestNode), false)] + [TestMethod] + public async Task Visit_WhenNodeIsParameterizedNodeAndPropertyIsAbsentOrTrue_ExpandNode(string parameterizedTestNode, bool hasExpansionProperty) + { + // Arrange + TestNode rootNode = CreateParameterizedTestNode(parameterizedTestNode, hasExpansionProperty ? false : null); + var visitor = new BFSTestNodeVisitor(new[] { rootNode }, new NopFilter(), new TestArgumentsManager()); + + // Act + List<(TestNode Node, TestNodeUid? ParentNodeUid)> includedTestNodes = new(); + await visitor.VisitAsync((testNode, parentNodeUid) => + { + includedTestNodes.Add((testNode, parentNodeUid)); + return Task.CompletedTask; + }); + + // Assert + Assert.AreEqual(3, includedTestNodes.Count); + + Assert.AreEqual("ID1", includedTestNodes[0].Node.StableUid); + Assert.AreEqual(null, includedTestNodes[0].ParentNodeUid); + + Assert.AreEqual("ID1 [0]", includedTestNodes[1].Node.StableUid); + Assert.AreEqual("ID1", includedTestNodes[1].ParentNodeUid); + + Assert.AreEqual("ID1 [1]", includedTestNodes[2].Node.StableUid); + Assert.AreEqual("ID1", includedTestNodes[2].ParentNodeUid); + } + + [DataRow(nameof(InternalUnsafeActionParameterizedTestNode))] + [TestMethod] + public async Task Visit_WhenNodeIsParameterizedNodeAndDoesNotAllowExpansion_DoesNotExpand(string parameterizedTestNode) + { + // Arrange + TestNode rootNode = CreateParameterizedTestNode(parameterizedTestNode, true); + var visitor = new BFSTestNodeVisitor(new[] { rootNode }, new NopFilter(), new TestArgumentsManager()); + + // Act + List<(TestNode Node, TestNodeUid? ParentNodeUid)> includedTestNodes = new(); + await visitor.VisitAsync((testNode, parentNodeUid) => + { + includedTestNodes.Add((testNode, parentNodeUid)); + return Task.CompletedTask; + }); + + // Assert + Assert.AreEqual(1, includedTestNodes.Count); + } + + [TestMethod] + public async Task Visit_WithModuleNamespaceClassMethodLevelAndExpansion_DiscoverTestsWithCorrectParentsAndTypes() + { + // Arrange + var rootNode = new TestNode + { + StableUid = "MyModule", + DisplayName = "MyModule", + Tests = new[] + { + new TestNode + { + StableUid = "MyNamespace", + DisplayName = "MyNamespace", + Tests = new[] + { + new TestNode + { + StableUid = "MyType", + DisplayName = "MyType", + Tests = new[] + { + new InternalUnsafeActionParameterizedTestNode + { + StableUid = "MyMethod", + DisplayName = "MyMethod", + GetArguments = () => new byte[] { 0, 1, 2 }, + Body = (_, _) => { }, + }, + }, + }, + }, + }, + }, + }; + var visitor = new BFSTestNodeVisitor(new[] { rootNode }, new NopFilter(), new TestArgumentsManager()); + + // Act + List<(TestNode Node, TestNodeUid? ParentNodeUid)> includedTestNodes = new(); + await visitor.VisitAsync((testNode, parentNodeUid) => + { + includedTestNodes.Add((testNode, parentNodeUid)); + return Task.CompletedTask; + }); + + // Assert + Assert.AreEqual(7, includedTestNodes.Count); + + Assert.AreEqual("MyModule", includedTestNodes[0].Node.StableUid); + Assert.AreEqual(null, includedTestNodes[0].ParentNodeUid); + Assert.AreEqual(typeof(TestNode), includedTestNodes[0].Node.GetType()); + + Assert.AreEqual("MyNamespace", includedTestNodes[1].Node.StableUid); + Assert.AreEqual("MyModule", includedTestNodes[1].ParentNodeUid); + Assert.AreEqual(typeof(TestNode), includedTestNodes[1].Node.GetType()); + + Assert.AreEqual("MyType", includedTestNodes[2].Node.StableUid); + Assert.AreEqual("MyNamespace", includedTestNodes[2].ParentNodeUid); + Assert.AreEqual(typeof(TestNode), includedTestNodes[2].Node.GetType()); + + Assert.AreEqual("MyMethod", includedTestNodes[3].Node.StableUid); + Assert.AreEqual("MyType", includedTestNodes[3].ParentNodeUid); + Assert.AreEqual(typeof(TestNode), includedTestNodes[3].Node.GetType()); + + Assert.AreEqual("MyMethod [0]", includedTestNodes[4].Node.StableUid); + Assert.AreEqual("MyMethod", includedTestNodes[4].ParentNodeUid); + Assert.AreEqual(typeof(InternalUnsafeActionTestNode), includedTestNodes[4].Node.GetType()); + + Assert.AreEqual("MyMethod [1]", includedTestNodes[5].Node.StableUid); + Assert.AreEqual("MyMethod", includedTestNodes[5].ParentNodeUid); + Assert.AreEqual(typeof(InternalUnsafeActionTestNode), includedTestNodes[5].Node.GetType()); + + Assert.AreEqual("MyMethod [2]", includedTestNodes[6].Node.StableUid); + Assert.AreEqual("MyMethod", includedTestNodes[6].ParentNodeUid); + Assert.AreEqual(typeof(InternalUnsafeActionTestNode), includedTestNodes[6].Node.GetType()); + } + + private static TestNode CreateParameterizedTestNode(string parameterizedTestNode, bool? expansionPropertyValue) + { + TestNode rootNode = parameterizedTestNode switch + { + nameof(InternalUnsafeActionParameterizedTestNode) => new InternalUnsafeActionParameterizedTestNode() + { + StableUid = "ID1", + DisplayName = "A", + Body = (_, _) => { }, + GetArguments = GetArguments, + Properties = GetProperties(expansionPropertyValue), + }, + _ => throw new ArgumentException($"Unknown test node type: {parameterizedTestNode}", nameof(parameterizedTestNode)), + }; + + return rootNode; + + // Local functions + static IEnumerable GetArguments() => new byte[] { 0, 1 }; + static IProperty[] GetProperties(bool? hasExpansionProperty) + => hasExpansionProperty.HasValue + ? new IProperty[1] + { + new FrameworkEngineMetadataProperty() + { + PreventArgumentsExpansion = hasExpansionProperty.Value, + }, + } + : Array.Empty(); + } +} diff --git a/test/UnitTests/MSTest.Engine.UnitTests/DataRowTests.cs b/test/UnitTests/MSTest.Engine.UnitTests/DataRowTests.cs new file mode 100644 index 0000000000..fb36b9924f --- /dev/null +++ b/test/UnitTests/MSTest.Engine.UnitTests/DataRowTests.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Microsoft.Testing.Framework.UnitTests; + +/// +/// This class uses DataRows, to prove that running such tests works. +/// +[TestClass] +public class DataRowTests +{ + [DataRow(1, 2)] + [DataRow(2, 3)] + [TestMethod] + public void DataRowDataAreConsumed(int expected, int actualPlus1) + => Assert.AreEqual(expected, actualPlus1 - 1); +} diff --git a/test/UnitTests/MSTest.Engine.UnitTests/DynamicDataNameProviderTests.cs b/test/UnitTests/MSTest.Engine.UnitTests/DynamicDataNameProviderTests.cs new file mode 100644 index 0000000000..f511c2ff51 --- /dev/null +++ b/test/UnitTests/MSTest.Engine.UnitTests/DynamicDataNameProviderTests.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Microsoft.Testing.Framework.UnitTests; + +[TestClass] +public class DynamicDataNameProviderTests +{ + [TestMethod] + public void NullTranslatesToNullString() + { + // Comment in DynamicDataAttribute says: + // We want to force call to `data.AsEnumerable()` to ensure that objects are casted to strings (using ToString()) + // so that null do appear as "null". If you remove the call, and do string.Join(",", new object[] { null, "a" }), + // you will get empty string while with the call you will get "null,a". + // + // check that this is still true: + string fragment = DynamicDataNameProvider.GetUidFragment(["parameter1", "parameter2"], new object?[] { null, "a" }, 0); + Assert.AreEqual("(parameter1: null, parameter2: a)[0]", fragment); + } + + [TestMethod] + public void ParameterMismatchShowsDataInMessage() + { + // Comment in DynamicDataAttribute says: + // We want to force call to `data.AsEnumerable()` to ensure that objects are casted to strings (using ToString()) + // so that null do appear as "null". If you remove the call, and do string.Join(",", new object[] { null, "a" }), + // you will get empty string while with the call you will get "null,a". + // + // check that this is still true: + ArgumentException exception = Assert.ThrowsException(() => DynamicDataNameProvider.GetUidFragment(["parameter1"], new object?[] { null, "a" }, 0)); + Assert.AreEqual("Parameter count mismatch. The provided data (null, a) have 2 items, but there are 1 parameters.", exception.Message); + } +} diff --git a/test/UnitTests/MSTest.Engine.UnitTests/DynamicDataTests.cs b/test/UnitTests/MSTest.Engine.UnitTests/DynamicDataTests.cs new file mode 100644 index 0000000000..ea608a39d3 --- /dev/null +++ b/test/UnitTests/MSTest.Engine.UnitTests/DynamicDataTests.cs @@ -0,0 +1,108 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Microsoft.Testing.Framework.UnitTests; + +/// +/// This class uses DynamicData, to prove that running such tests works. +/// +[TestClass] +public class DynamicDataTests +{ + public static IEnumerable IntDataProperty + => new[] + { + new object[] { 1, 2 }, + new object[] { 2, 3 }, + }; + + [DynamicData(nameof(IntDataProperty))] + [TestMethod] + public void DynamicDataWithIntProperty(int expected, int actualPlus1) + => Assert.AreEqual(expected, actualPlus1 - 1); + + [DynamicData(nameof(IntDataProperty), DynamicDataSourceType.Property)] + [TestMethod] + public void DynamicDataWithIntPropertyAndExplicitSourceType(int expected, int actualPlus1) + => Assert.AreEqual(expected, actualPlus1 - 1); + + [DynamicData(nameof(IntDataMethod))] + [TestMethod] + public void DynamicDataWithIntMethod(int expected, int actualPlus1) + => Assert.AreEqual(expected, actualPlus1 - 1); + + [DynamicData(nameof(IntDataMethod), DynamicDataSourceType.Method)] + [TestMethod] + public void DynamicDataWithIntMethodAndExplicitSourceType(int expected, int actualPlus1) + => Assert.AreEqual(expected, actualPlus1 - 1); + + public static IEnumerable IntDataMethod() + => new[] + { + new object[] { 1, 2 }, + new object[] { 2, 3 }, + }; + + [DynamicData(nameof(IntDataProperty), typeof(DataClass))] + [TestMethod] + public void DynamicDataWithIntPropertyOnSeparateClass(int expected, int actualPlus2) + => Assert.AreEqual(expected, actualPlus2 - 2); + + [DynamicData(nameof(IntDataMethod), typeof(DataClass), DynamicDataSourceType.Method)] + [TestMethod] + public void DynamicDataWithIntMethodOnSeparateClass(int expected, int actualPlus2) + => Assert.AreEqual(expected, actualPlus2 - 2); + + [DynamicData(nameof(UserDataProperty))] + [TestMethod] + public void DynamicDataWithUserProperty(User _, User _2) + { + } + + public static IEnumerable UserDataProperty + => new[] + { + new object[] { new User("Jakub"), new User("Amaury") }, + new object[] { new User("Marco"), new User("Pavel") }, + }; + + [DynamicData(nameof(UserDataMethod), DynamicDataSourceType.Method)] + [TestMethod] + public void DynamicDataWithUserMethod(User _, User _2) + { + } + + public static IEnumerable UserDataMethod() + => new[] + { + new object[] { new User("Jakub"), new User("Amaury") }, + new object[] { new User("Marco"), new User("Pavel") }, + }; +} + +public class DataClass +{ + public static IEnumerable IntDataProperty + => new[] + { + new object[] { 1, 3 }, + new object[] { 2, 4 }, + }; + + public static IEnumerable IntDataMethod() + => new[] + { + new object[] { 1, 3 }, + new object[] { 2, 4 }, + }; +} + +public class User +{ + public User(string name) + { + Name = name; + } + + public string Name { get; } +} diff --git a/test/UnitTests/MSTest.Engine.UnitTests/MSTest.Engine.UnitTests.csproj b/test/UnitTests/MSTest.Engine.UnitTests/MSTest.Engine.UnitTests.csproj new file mode 100644 index 0000000000..1bc6a665dc --- /dev/null +++ b/test/UnitTests/MSTest.Engine.UnitTests/MSTest.Engine.UnitTests.csproj @@ -0,0 +1,36 @@ + + + + $(TargetFrameworks);net462 + Exe + true + true + false + true + Microsoft.Testing.Framework.UnitTests + + + + + PreserveNewest + + + + + + + + MicrosoftTestingPlatformMSBuild + + + + + + + + Analyzer + false + + + + diff --git a/test/UnitTests/MSTest.Engine.UnitTests/MSTest.Engine.UnitTests.launcher.config.json b/test/UnitTests/MSTest.Engine.UnitTests/MSTest.Engine.UnitTests.launcher.config.json new file mode 100644 index 0000000000..772598e7f9 --- /dev/null +++ b/test/UnitTests/MSTest.Engine.UnitTests/MSTest.Engine.UnitTests.launcher.config.json @@ -0,0 +1,3 @@ +{ + "program": "MSTest.Engine.UnitTests.exe" +} diff --git a/test/UnitTests/MSTest.Engine.UnitTests/MSTest.Engine.UnitTests.testingplatformconfig.json b/test/UnitTests/MSTest.Engine.UnitTests/MSTest.Engine.UnitTests.testingplatformconfig.json new file mode 100644 index 0000000000..4c41cda3a3 --- /dev/null +++ b/test/UnitTests/MSTest.Engine.UnitTests/MSTest.Engine.UnitTests.testingplatformconfig.json @@ -0,0 +1,8 @@ +{ + "testingplatform": { + "telemetry": { + "isDevelopmentRepository": true + }, + "exitProcessOnUnhandledException": true + } +} diff --git a/test/UnitTests/MSTest.Engine.UnitTests/Program.cs b/test/UnitTests/MSTest.Engine.UnitTests/Program.cs new file mode 100644 index 0000000000..79206b730e --- /dev/null +++ b/test/UnitTests/MSTest.Engine.UnitTests/Program.cs @@ -0,0 +1,29 @@ +// 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; + +using ExecutionScope = Microsoft.VisualStudio.TestTools.UnitTesting.ExecutionScope; + +[assembly: Parallelize(Scope = ExecutionScope.MethodLevel, Workers = 0)] +[assembly: ClassCleanupExecution(ClassCleanupBehavior.EndOfClass)] + +ITestApplicationBuilder builder = await TestApplication.CreateBuilderAsync(args); +builder.AddMSTest(() => [Assembly.GetEntryAssembly()!]); + +#if ENABLE_CODECOVERAGE +builder.AddCodeCoverageProvider(); +#endif +builder.AddHangDumpProvider(); +builder.AddCrashDumpProvider(ignoreIfNotSupported: true); +builder.AddTrxReportProvider(); +builder.AddAppInsightsTelemetryProvider(); + +// Custom suite tools +CompositeExtensionFactory slowestTestCompositeServiceFactory + = new(_ => new SlowestTestsConsumer()); +builder.TestHost.AddDataConsumer(slowestTestCompositeServiceFactory); +builder.TestHost.AddTestSessionLifetimeHandle(slowestTestCompositeServiceFactory); + +using ITestApplication app = await builder.BuildAsync(); +return await app.RunAsync(); diff --git a/test/UnitTests/MSTest.Engine.UnitTests/Properties/launchSettings.json b/test/UnitTests/MSTest.Engine.UnitTests/Properties/launchSettings.json new file mode 100644 index 0000000000..33f86c8045 --- /dev/null +++ b/test/UnitTests/MSTest.Engine.UnitTests/Properties/launchSettings.json @@ -0,0 +1,11 @@ +{ + "profiles": { + "MSTest.Engine.UnitTests": { + "commandName": "Project", + "commandLineArgs": "--treenode-filter /*/*/*/**", + "environmentVariables": { + //"TESTINGPLATFORM_HOTRELOAD_ENABLED": "1" + } + } + } +} diff --git a/test/UnitTests/MSTest.Engine.UnitTests/TestBase.cs b/test/UnitTests/MSTest.Engine.UnitTests/TestBase.cs new file mode 100644 index 0000000000..9c09f265b5 --- /dev/null +++ b/test/UnitTests/MSTest.Engine.UnitTests/TestBase.cs @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Microsoft.Testing.Framework.UnitTests; + +/// +/// Empty test base, because TestInfrastructure project depends on Testing.Framework, and we cannot use that. +/// +public abstract class TestBase +{ +} diff --git a/test/UnitTests/MSTest.SourceGeneration.UnitTests/Generators/DataRowAttributeGenerationTests.cs b/test/UnitTests/MSTest.SourceGeneration.UnitTests/Generators/DataRowAttributeGenerationTests.cs new file mode 100644 index 0000000000..352b28d89f --- /dev/null +++ b/test/UnitTests/MSTest.SourceGeneration.UnitTests/Generators/DataRowAttributeGenerationTests.cs @@ -0,0 +1,843 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using FluentAssertions; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; +using Microsoft.Testing.Framework.SourceGeneration.UnitTests.Helpers; +using Microsoft.Testing.Framework.SourceGeneration.UnitTests.TestUtilities; + +namespace Microsoft.Testing.Framework.SourceGeneration.UnitTests.Generators; + +[TestClass] +public sealed class DataRowAttributeGenerationTests : TestBase +{ + [TestMethod] + public async Task DataRowAttribute_HandlesPrimitiveTypes() + { + GeneratorCompilationResult generatorResult = await GeneratorTester.TestGraph.CompileAndExecuteAsync( + """ + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace MyNamespace + { + [TestClass] + public class TestClass + { + [TestMethod] + [DataRow(true)] + public Task MethodWithBool(bool b) + => Task.CompletedTask; + + [TestMethod] + [DataRow(1)] + public Task MethodWithByte(byte b) + => Task.CompletedTask; + + [TestMethod] + [DataRow(1)] + public Task MethodWithSbyte(sbyte b) + => Task.CompletedTask; + + [TestMethod] + [DataRow('a')] + public Task MethodWithChar(char c) + => Task.CompletedTask; + + [TestMethod] + [DataRow(1)] + public Task MethodWithDecimal(decimal d) + => Task.CompletedTask; + + [TestMethod] + [DataRow(1)] + public Task MethodWithDouble(double d) + => Task.CompletedTask; + + [TestMethod] + [DataRow(1)] + public Task MethodWithFloat(float f) + => Task.CompletedTask; + + [TestMethod] + [DataRow(1)] + public Task MethodWithInt(int i) + => Task.CompletedTask; + + [TestMethod] + [DataRow(1)] + public Task MethodWithUInt(uint i) + => Task.CompletedTask; + + [TestMethod] + [DataRow(1)] + public Task MethodWithLong(long l) + => Task.CompletedTask; + + [TestMethod] + [DataRow(1)] + public Task MethodWithULong(ulong l) + => Task.CompletedTask; + + [TestMethod] + [DataRow(1)] + public Task MethodWithShort(short s) + => Task.CompletedTask; + + [TestMethod] + [DataRow(1)] + public Task MethodWithUShort(ushort s) + => Task.CompletedTask; + } + } + """, CancellationToken.None); + generatorResult.AssertSuccessfulGeneration(); + + SyntaxTree? testClassTree = generatorResult.GeneratedTrees.FirstOrDefault(r => r.FilePath.EndsWith("TestClass.g.cs", StringComparison.OrdinalIgnoreCase)); + testClassTree.Should().NotBeNull(); + + SourceText testClass = await testClassTree!.GetTextAsync(); + + testClass.Should().ContainSourceCode(""" + GetArguments = static () => new MSTF::InternalUnsafeTestArgumentsEntry[] + { + new MSTF::InternalUnsafeTestArgumentsEntry(true, "b: true"), + }, + """); + testClass.Should().ContainSourceCode(""" + GetArguments = static () => new MSTF::InternalUnsafeTestArgumentsEntry[] + { + new MSTF::InternalUnsafeTestArgumentsEntry(1, "b: 1"), + }, + """); + testClass.Should().ContainSourceCode(""" + GetArguments = static () => new MSTF::InternalUnsafeTestArgumentsEntry[] + { + new MSTF::InternalUnsafeTestArgumentsEntry(1, "b: 1"), + }, + """); + testClass.Should().ContainSourceCode(""" + GetArguments = static () => new MSTF::InternalUnsafeTestArgumentsEntry[] + { + new MSTF::InternalUnsafeTestArgumentsEntry('a', "c: 'a'"), + }, + """); + testClass.Should().ContainSourceCode(""" + GetArguments = static () => new MSTF::InternalUnsafeTestArgumentsEntry[] + { + new MSTF::InternalUnsafeTestArgumentsEntry(1, "d: 1"), + }, + """); + testClass.Should().ContainSourceCode(""" + GetArguments = static () => new MSTF::InternalUnsafeTestArgumentsEntry[] + { + new MSTF::InternalUnsafeTestArgumentsEntry(1, "d: 1"), + }, + """); + testClass.Should().ContainSourceCode(""" + GetArguments = static () => new MSTF::InternalUnsafeTestArgumentsEntry[] + { + new MSTF::InternalUnsafeTestArgumentsEntry(1, "f: 1"), + }, + """); + testClass.Should().ContainSourceCode(""" + GetArguments = static () => new MSTF::InternalUnsafeTestArgumentsEntry[] + { + new MSTF::InternalUnsafeTestArgumentsEntry(1, "i: 1"), + }, + """); + testClass.Should().ContainSourceCode(""" + GetArguments = static () => new MSTF::InternalUnsafeTestArgumentsEntry[] + { + new MSTF::InternalUnsafeTestArgumentsEntry(1, "i: 1"), + }, + """); + testClass.Should().ContainSourceCode(""" + GetArguments = static () => new MSTF::InternalUnsafeTestArgumentsEntry[] + { + new MSTF::InternalUnsafeTestArgumentsEntry(1, "l: 1"), + }, + """); + testClass.Should().ContainSourceCode(""" + GetArguments = static () => new MSTF::InternalUnsafeTestArgumentsEntry[] + { + new MSTF::InternalUnsafeTestArgumentsEntry(1, "l: 1"), + }, + """); + testClass.Should().ContainSourceCode(""" + GetArguments = static () => new MSTF::InternalUnsafeTestArgumentsEntry[] + { + new MSTF::InternalUnsafeTestArgumentsEntry(1, "s: 1"), + }, + """); + testClass.Should().ContainSourceCode(""" + GetArguments = static () => new MSTF::InternalUnsafeTestArgumentsEntry[] + { + new MSTF::InternalUnsafeTestArgumentsEntry(1, "s: 1"), + }, + """); + } + + [TestMethod] + public async Task DataRowAttribute_WhenGivenMultipleValues_GeneratesTupleData() + { + GeneratorCompilationResult generatorResult = await GeneratorTester.TestGraph.CompileAndExecuteAsync( + """ + using System; + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace MyNamespace + { + [TestClass] + public class TestClass + { + [TestMethod, DataRow("a", 1)] + public Task TestMethod(string s, int i) + { + return Task.CompletedTask; + } + + [TestMethod, DataRow("a", 1, true, 1.0)] + public Task TestMethod(string s, int i, bool b, double d) + { + return Task.CompletedTask; + } + } + } + """, CancellationToken.None); + generatorResult.AssertSuccessfulGeneration(); + + SyntaxTree? testClassTree = generatorResult.GeneratedTrees.FirstOrDefault(r => r.FilePath.EndsWith("TestClass.g.cs", StringComparison.OrdinalIgnoreCase)); + testClassTree.Should().NotBeNull(); + + SourceText testClass = await testClassTree!.GetTextAsync(); + + testClass.Should().ContainSourceCode(""" + GetArguments = static () => new MSTF::InternalUnsafeTestArgumentsEntry<(string s, int i)>[] + { + new MSTF::InternalUnsafeTestArgumentsEntry<(string s, int i)>(("a", 1), "s: \"a\", i: 1"), + }, + """); + testClass.Should().ContainSourceCode(""" + GetArguments = static () => new MSTF::InternalUnsafeTestArgumentsEntry<(string s, int i, bool b, double d)>[] + { + new MSTF::InternalUnsafeTestArgumentsEntry<(string s, int i, bool b, double d)>(("a", 1, true, 1), "s: \"a\", i: 1, b: true, d: 1"), + }, + """); + } + + [TestMethod] + public async Task DataRowAttribute_HandlesEscapedStrings() + { + GeneratorCompilationResult generatorResult = await GeneratorTester.TestGraph.CompileAndExecuteAsync( + """ + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace MyNamespace + { + [TestClass] + public class TestClass + { + [TestMethod] + [DataRow("\"abc\"")] + [DataRow(@"a\b\c")] + public Task TestMethod(string a) + { + return Task.CompletedTask; + } + } + } + """, CancellationToken.None); + generatorResult.AssertSuccessfulGeneration(); + + SyntaxTree? testClassTree = generatorResult.GeneratedTrees.FirstOrDefault(r => r.FilePath.EndsWith("TestClass.g.cs", StringComparison.OrdinalIgnoreCase)); + testClassTree.Should().NotBeNull(); + + SourceText testClass = await testClassTree!.GetTextAsync(); + + testClass.Should().ContainSourceCode(""" + GetArguments = static () => new MSTF::InternalUnsafeTestArgumentsEntry[] + { + new MSTF::InternalUnsafeTestArgumentsEntry("\"abc\"", "a: \"\"abc\"\""), + new MSTF::InternalUnsafeTestArgumentsEntry("a\\b\\c", "a: \"a\\b\\c\""), + }, + """); + } + + [TestMethod] + public async Task DataRowAttribute_WhenGivenTypeofOtherType_GeneratesDataWithFullType() + { + GeneratorCompilationResult generatorResult = await GeneratorTester.TestGraph.CompileAndExecuteAsync( + """ + using System; + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace MyNamespace + { + internal class MyClass + { + + } + + [TestClass] + public class TestClass + { + [TestMethod] + [DataRow(typeof(MyClass))] + public Task TestMethod(Type a) + { + return Task.CompletedTask; + } + } + } + """, CancellationToken.None); + + generatorResult.AssertSuccessfulGeneration(); + SyntaxTree? testClassTree = generatorResult.GeneratedTrees.FirstOrDefault(r => r.FilePath.EndsWith("TestClass.g.cs", StringComparison.OrdinalIgnoreCase)); + testClassTree.Should().NotBeNull(); + + SourceText testClass = await testClassTree!.GetTextAsync(); + + testClass.Should().ContainSourceCode(""" + GetArguments = static () => new MSTF::InternalUnsafeTestArgumentsEntry[] + { + new MSTF::InternalUnsafeTestArgumentsEntry(typeof(MyNamespace.MyClass), "a: typeof(MyNamespace.MyClass)"), + }, + """); + } + + [TestMethod] + public async Task DataRowAttribute_WithEnumAsChildFromParentClass_GeneratesDataWithFullType() + { + GeneratorCompilationResult generatorResult = await GeneratorTester.TestGraph.CompileAndExecuteAsync( + """ + using System; + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace MyNamespace + { + [TestClass] + public class ParentClass + { + + public enum MyEnum + { + One, + } + + [TestClass] + public class TestClass + { + [TestMethod, DataRow(MyEnum.One)] + public Task TestMethod(MyEnum a) + { + return Task.CompletedTask; + } + } + } + } + """, CancellationToken.None); + + generatorResult.AssertSuccessfulGeneration(); + SyntaxTree? testClassTree = generatorResult.GeneratedTrees.FirstOrDefault(r => r.FilePath.EndsWith("TestClass.g.cs", StringComparison.OrdinalIgnoreCase)); + testClassTree.Should().NotBeNull(); + + SourceText testClass = await testClassTree!.GetTextAsync(); + + testClass.Should().ContainSourceCode(""" + GetArguments = static () => new MSTF::InternalUnsafeTestArgumentsEntry[] + { + new MSTF::InternalUnsafeTestArgumentsEntry(global::MyNamespace.ParentClass.MyEnum.One, "a: global::MyNamespace.ParentClass.MyEnum.One"), + }, + """); + } + + [TestMethod] + public async Task DataRowAttribute_WithEnumAsSubNamespaceDoesNotShadowTypeFromAnotherNamespace_GeneratesDataWithFullGlobalType() + { + GeneratorCompilationResult generatorResult = await GeneratorTester.TestGraph.CompileAndExecuteAsync( + """ + using System; + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + // This gives us a way to reference to the MyEnum (1) + // in the DataRow. + using MyEnum = global::ConflictingNamespace.MyEnum; + + namespace ConflictingNamespace { + public enum MyEnum // 1 + { + One, + } + } + + namespace MyNamespace + { + namespace ConflictingNamespace { + public enum MyEnum // 2 + { + Two, + } + } + + [TestClass] + public class TestClass + { + // If the generated code from here emits just + // ConflictingNamespace.MyEnum.One, we will get an error + // saying that MyEnum does not have definition for One, + // because we are resolving the type by the relative namespace, + // and so we find MyNamespace.ConflictingNamespace.MyEnum, which is MyEnum (2). + [TestMethod, DataRow(MyEnum.One)] + public Task TestMethod(MyEnum a) + { + return Task.CompletedTask; + } + } + } + """, CancellationToken.None); + + generatorResult.AssertSuccessfulGeneration(); + SyntaxTree? testClassTree = generatorResult.GeneratedTrees.FirstOrDefault(r => r.FilePath.EndsWith("TestClass.g.cs", StringComparison.OrdinalIgnoreCase)); + testClassTree.Should().NotBeNull(); + + SourceText testClass = await testClassTree!.GetTextAsync(); + + testClass.Should().ContainSourceCode(""" + GetArguments = static () => new MSTF::InternalUnsafeTestArgumentsEntry[] + { + new MSTF::InternalUnsafeTestArgumentsEntry(global::ConflictingNamespace.MyEnum.One, "a: global::ConflictingNamespace.MyEnum.One"), + }, + """); + } + + [TestMethod] + public async Task DataRowAttribute_WithEnumAsSubNamespaceDoesNotShadowTypeFromAnotherNamespaceAndUsesChildType_GeneratesDataWithFullGlobalType() + { + GeneratorCompilationResult generatorResult = await GeneratorTester.TestGraph.CompileAndExecuteAsync( + """ + using System; + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace ConflictingNamespace { + public enum MyEnum // 1 + { + One, + } + } + + namespace MyNamespace + { + + namespace ConflictingNamespace { + public enum MyEnum // 2 + { + Two, + } + } + + [TestClass] + public class TestClass + { + // This refers to MyEnum (2), we should emit a full type + // with global:: into the code. + [TestMethod, DataRow(ConflictingNamespace.MyEnum.Two)] + public Task TestMethod(ConflictingNamespace.MyEnum a) + { + return Task.CompletedTask; + } + } + } + """, CancellationToken.None); + + generatorResult.AssertSuccessfulGeneration(); + SyntaxTree? testClassTree = generatorResult.GeneratedTrees.FirstOrDefault(r => r.FilePath.EndsWith("TestClass.g.cs", StringComparison.OrdinalIgnoreCase)); + testClassTree.Should().NotBeNull(); + + SourceText testClass = await testClassTree!.GetTextAsync(); + + testClass.Should().ContainSourceCode(""" + GetArguments = static () => new MSTF::InternalUnsafeTestArgumentsEntry[] + { + new MSTF::InternalUnsafeTestArgumentsEntry(global::MyNamespace.ConflictingNamespace.MyEnum.Two, "a: global::MyNamespace.ConflictingNamespace.MyEnum.Two"), + }, + """); + } + + [TestMethod] + public async Task DataRowAttribute_GivenNullValues_GeneratesCorrectData() + { + GeneratorCompilationResult generatorResult = await GeneratorTester.TestGraph.CompileAndExecuteAsync( + """ + using System; + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace MyNamespace + { + [TestClass] + public class TestClass + { + [TestMethod, DataRow(null)] + public Task TestMethod1(string a) + { + return Task.CompletedTask; + } + + [TestMethod, DataRow(null)] + public Task TestMethod1(object a) + { + return Task.CompletedTask; + } + + [TestMethod, DataRow(null, null)] + public Task TestMethod1(string s, object a) + { + return Task.CompletedTask; + } + } + } + """, CancellationToken.None); + + generatorResult.AssertSuccessfulGeneration(); + SyntaxTree? testClassTree = generatorResult.GeneratedTrees.FirstOrDefault(r => r.FilePath.EndsWith("TestClass.g.cs", StringComparison.OrdinalIgnoreCase)); + testClassTree.Should().NotBeNull(); + + SourceText testClass = await testClassTree!.GetTextAsync(); + + testClass.Should().ContainSourceCode(""" + GetArguments = static () => new MSTF::InternalUnsafeTestArgumentsEntry[] + { + new MSTF::InternalUnsafeTestArgumentsEntry(null, "a: null"), + }, + """); + testClass.Should().ContainSourceCode(""" + GetArguments = static () => new MSTF::InternalUnsafeTestArgumentsEntry[] + { + new MSTF::InternalUnsafeTestArgumentsEntry(null, "a: null"), + }, + """); + testClass.Should().ContainSourceCode(""" + GetArguments = static () => new MSTF::InternalUnsafeTestArgumentsEntry<(string s, object a)>[] + { + new MSTF::InternalUnsafeTestArgumentsEntry<(string s, object a)>((null, null), "s: null, a: null"), + }, + """); + } + + [TestMethod] + public async Task DataRowAttribute_WhenMissingAttribute_OutputsCommentAboveTheTestNodeAndFailsToCompile() + { + GeneratorCompilationResult generatorResult = await GeneratorTester.TestGraph.CompileAndExecuteAsync( + """ + using System; + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace MyNamespace + { + [TestClass] + public class TestClass + { + [TestMethod] + public Task TestMethod2(string a, string b) + { + return Task.CompletedTask; + } + } + } + """, CancellationToken.None); + + generatorResult.AssertFailedGeneration( + "*CS0308: The non-generic type 'InternalUnsafeAsyncActionTestNode' cannot be used with type arguments*", + "*CS9035: Required member 'TestNode.StableUid' must be set in the object initializer or attribute constructor.*", + "*CS9035: Required member 'TestNode.DisplayName' must be set in the object initializer or attribute constructor.*", + "*CS9035: Required member 'InternalUnsafeAsyncActionTestNode.Body' must be set in the object initializer or attribute constructor.*"); + + SyntaxTree? testClassTree = generatorResult.GeneratedTrees.FirstOrDefault(r => r.FilePath.EndsWith("TestClass.g.cs", StringComparison.OrdinalIgnoreCase)); + testClassTree.Should().NotBeNull(); + + SourceText testClass = await testClassTree!.GetTextAsync(); + + testClass.Should().ContainSourceCode(""" + // The test method is parameterized but no argument was specified. + // This is most often caused by using an unsupported arguments input. + // Possible resolutions: + // - There is a mismatch between arguments from [DataRow] and the method parameters. + // - There is a mismatch between arguments from [DynamicData] and the method parameters. + // If nothing else worked, report the error and exclude this method by using [Ignore]. + new MSTF::InternalUnsafeAsyncActionTestNode<(string a, string b)> + """); + } + + [TestMethod] + public async Task Arguments_WithMisalignedDataTypes_ItOutputsTheCodeAndFailsToCompile() + { + GeneratorCompilationResult generatorResult = await GeneratorTester.TestGraph.CompileAndExecuteAsync( + """ + using System; + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace MyNamespace + { + [TestClass] + public class TestClass + { + [TestMethod, DataRow("a", "b", "c")] + public Task TestWithTooMuchData(string a, string b) + { + return Task.CompletedTask; + } + + [TestMethod, DataRow("a", "b")] + public Task TestWithNotEnoughData(string a, string b, string c) + { + return Task.CompletedTask; + } + + [TestMethod, DataRow(1, 1)] + public Task TestWithMismatchedDataTypes(string a, string b) + { + return Task.CompletedTask; + } + } + } + """, CancellationToken.None); + + generatorResult.AssertFailedGeneration( + "*error CS1503: Argument 1: cannot convert from '(string, string, string)' to '(string a, string b)'*", + "*error CS1503: Argument 1: cannot convert from '(string, string)' to '(string a, string b, string c)'*", + "*error CS1503: Argument 1: cannot convert from '(int, int)' to '(string a, string b)'*"); + } + + [TestMethod] + public async Task Arguments_GivenAnArrayOfObjects_GeneratesCorrectData() + { + GeneratorCompilationResult generatorResult = await GeneratorTester.TestGraph.CompileAndExecuteAsync( + """ + using System; + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace MyNamespace + { + [TestClass] + public class TestClass + { + [TestMethod, DataRow(new object[] { 1, (object)"a" })] + public Task OneObjectArray(object[] args) + { + return Task.CompletedTask; + } + } + } + """, CancellationToken.None); + + generatorResult.AssertSuccessfulGeneration(); + SyntaxTree? testClassTree = generatorResult.GeneratedTrees.FirstOrDefault(r => r.FilePath.EndsWith("TestClass.g.cs", StringComparison.OrdinalIgnoreCase)); + testClassTree.Should().NotBeNull(); + + SourceText testClass = await testClassTree!.GetTextAsync(); + + testClass.Should().ContainSourceCode(""" + GetArguments = static () => new MSTF::InternalUnsafeTestArgumentsEntry[] + { + new MSTF::InternalUnsafeTestArgumentsEntry(new object[] { 1, "a" }, "args: new object[] { 1, \"a\" }"), + }, + """); + } + + [TestMethod] + public async Task Arguments_GivenAnArrayOfInt_GeneratesCorrectData() + { + GeneratorCompilationResult generatorResult = await GeneratorTester.TestGraph.CompileAndExecuteAsync( + """ + using System; + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace MyNamespace + { + [TestClass] + public class TestClass + { + [TestMethod, DataRow(new int[] { 1, 2 })] + public Task OneIntArray(int[] args) + { + return Task.CompletedTask; + } + } + } + """, CancellationToken.None); + + generatorResult.AssertSuccessfulGeneration(); + SyntaxTree? testClassTree = generatorResult.GeneratedTrees.FirstOrDefault(r => r.FilePath.EndsWith("TestClass.g.cs", StringComparison.OrdinalIgnoreCase)); + testClassTree.Should().NotBeNull(); + + SourceText testClass = await testClassTree!.GetTextAsync(); + + testClass.Should().ContainSourceCode(""" + GetArguments = static () => new MSTF::InternalUnsafeTestArgumentsEntry[] + { + new MSTF::InternalUnsafeTestArgumentsEntry(new int[] { 1, 2 }, "args: new int[] { 1, 2 }"), + }, + """); + } + + [TestMethod] + public async Task Arguments_GivenAnArrayOfString_GeneratesCorrectData() + { + GeneratorCompilationResult generatorResult = await GeneratorTester.TestGraph.CompileAndExecuteAsync( + """ + using System; + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace MyNamespace + { + [TestClass] + public class TestClass + { + [TestMethod, DataRow(new string[] { "a", "b" })] + public Task OneStringArray(string[] args) + { + return Task.CompletedTask; + } + } + } + """, CancellationToken.None); + + generatorResult.AssertSuccessfulGeneration(); + SyntaxTree? testClassTree = generatorResult.GeneratedTrees.FirstOrDefault(r => r.FilePath.EndsWith("TestClass.g.cs", StringComparison.OrdinalIgnoreCase)); + testClassTree.Should().NotBeNull(); + + SourceText testClass = await testClassTree!.GetTextAsync(); + + testClass.Should().ContainSourceCode(""" + GetArguments = static () => new MSTF::InternalUnsafeTestArgumentsEntry[] + { + new MSTF::InternalUnsafeTestArgumentsEntry(new string[] { "a", "b" }, "args: new string[] { \"a\", \"b\" }"), + }, + """); + } + + [TestMethod] + public async Task Arguments_GivenMultipleArgumentsAndMethodAcceptsSingleObjectArray_GeneratesCorrectData() + { + GeneratorCompilationResult generatorResult = await GeneratorTester.TestGraph.CompileAndExecuteAsync( + """ + using System; + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace MyNamespace + { + [TestClass] + public class TestClass + { + [TestMethod, DataRow(1, "a")] + public Task OneParamsObjectArray2(object[] args) + { + return Task.CompletedTask; + } + } + } + """, CancellationToken.None); + + SyntaxTree? testClassTree = generatorResult.GeneratedTrees.FirstOrDefault(r => r.FilePath.EndsWith("TestClass.g.cs", StringComparison.OrdinalIgnoreCase)); + testClassTree.Should().NotBeNull(); + + SourceText testClass = await testClassTree!.GetTextAsync(); + + testClass.Should().ContainSourceCode(""" + GetArguments = static () => new MSTF::InternalUnsafeTestArgumentsEntry[] + { + new MSTF::InternalUnsafeTestArgumentsEntry(new object?[] { 1, "a" }, "args: new object?[] { 1, \"a\" }"), + }, + """); + } + + [TestMethod] + public async Task Arguments_GivenArrayOfIntWhenMethodExpectsArrayOfObjects_FailsCompilation() + { + GeneratorCompilationResult generatorResult = await GeneratorTester.TestGraph.CompileAndExecuteAsync( + """ + using System; + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace MyNamespace + { + [TestClass] + public class TestClass + { + [TestMethod, DataRow(new int[] { 1, 2 })] + public Task OneIntArray(object[] args) + { + return Task.CompletedTask; + } + } + } + """, CancellationToken.None); + + generatorResult.AssertFailedGeneration("*error CS1503: Argument 1: cannot convert from 'int[]' to 'object[]'*"); + + SyntaxTree? testClassTree = generatorResult.GeneratedTrees.FirstOrDefault(r => r.FilePath.EndsWith("TestClass.g.cs", StringComparison.OrdinalIgnoreCase)); + testClassTree.Should().NotBeNull(); + + SourceText testClass = await testClassTree!.GetTextAsync(); + + testClass.Should().ContainSourceCode(""" + GetArguments = static () => new MSTF::InternalUnsafeTestArgumentsEntry[] + { + new MSTF::InternalUnsafeTestArgumentsEntry(new int[] { 1, 2 }, "args: new int[] { 1, 2 }"), + }, + """); + } + + [TestMethod] + public async Task Arguments_GivenMultipleArrays_GeneratesCorrectData() + { + GeneratorCompilationResult generatorResult = await GeneratorTester.TestGraph.CompileAndExecuteAsync( + """ + using System; + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace MyNamespace + { + [TestClass] + public class TestClass + { + [TestMethod, DataRow(new object[] { 1, 2 }, new object[] { "a", 1 })] + public Task TwoObjectArrays(object[] args, object[] args2) + { + return Task.CompletedTask; + } + } + } + """, CancellationToken.None); + + generatorResult.AssertSuccessfulGeneration(); + SyntaxTree? testClassTree = generatorResult.GeneratedTrees.FirstOrDefault(r => r.FilePath.EndsWith("TestClass.g.cs", StringComparison.OrdinalIgnoreCase)); + testClassTree.Should().NotBeNull(); + + SourceText testClass = await testClassTree!.GetTextAsync(); + + testClass.Should().ContainSourceCode(""" + GetArguments = static () => new MSTF::InternalUnsafeTestArgumentsEntry<(object[] args, object[] args2)>[] + { + new MSTF::InternalUnsafeTestArgumentsEntry<(object[] args, object[] args2)>((new object[] { 1, 2 }, new object[] { "a", 1 }), "args: new object[] { 1, 2 }, args2: new object[] { \"a\", 1 }"), + }, + """); + } +} diff --git a/test/UnitTests/MSTest.SourceGeneration.UnitTests/Generators/DynamicDataAttributeTests.cs b/test/UnitTests/MSTest.SourceGeneration.UnitTests/Generators/DynamicDataAttributeTests.cs new file mode 100644 index 0000000000..1016eaf683 --- /dev/null +++ b/test/UnitTests/MSTest.SourceGeneration.UnitTests/Generators/DynamicDataAttributeTests.cs @@ -0,0 +1,119 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using FluentAssertions; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; +using Microsoft.Testing.Framework.SourceGeneration.UnitTests.Helpers; +using Microsoft.Testing.Framework.SourceGeneration.UnitTests.TestUtilities; + +namespace Microsoft.Testing.Framework.SourceGeneration.UnitTests.Generators; + +[TestClass] +public sealed class DynamicDataAttributeGenerationTests : TestBase +{ + [TestMethod] + public async Task DynamicDataAttribute_TakesDataFromProperty() + { + GeneratorCompilationResult generatorResult = await GeneratorTester.TestGraph.CompileAndExecuteAsync( + """ + using System.Threading.Tasks; + using System.Collections.Generic; + using Microsoft.VisualStudio.TestTools.UnitTesting; + using Microsoft.Testing.Framework; + + namespace MyNamespace + { + [TestClass] + public class TestClass + { + [DynamicData(nameof(Data))] + [TestMethod] + public void TestMethod(int expected, int actualPlus1) + => Assert.AreEqual(expected, actualPlus1 - 1); + + public static IEnumerable Data => new[] + { + new object[] { 1, 2 }, + new object[] { 2, 3 }, + }; + } + + } + """, CancellationToken.None); + generatorResult.AssertSuccessfulGeneration(); + + SyntaxTree? testClassTree = generatorResult.GeneratedTrees.FirstOrDefault(r => r.FilePath.EndsWith("TestClass.g.cs", StringComparison.OrdinalIgnoreCase)); + testClassTree.Should().NotBeNull(); + + SourceText testClass = await testClassTree!.GetTextAsync(); + + testClass.Should().ContainSourceCode(""" + GetArguments = static () => { + var data = MyNamespace.TestClass.Data; + var dataCollection = new ColGen.List>(); + var index = 0; + foreach (var item in data) + { + string uidFragment = MSTF::DynamicDataNameProvider.GetUidFragment(new string[] {"expected", "actualPlus1"}, item, index); + index++; + dataCollection.Add(new(((int) item[0], (int) item[1]), uidFragment)); + } + return dataCollection; + } + """); + } + + [TestMethod] + public async Task DynamicDataAttribute_TakesDataFromMethod() + { + GeneratorCompilationResult generatorResult = await GeneratorTester.TestGraph.CompileAndExecuteAsync( + """ + using System.Threading.Tasks; + using System.Collections.Generic; + using Microsoft.VisualStudio.TestTools.UnitTesting; + using Microsoft.Testing.Framework; + + namespace MyNamespace + { + [TestClass] + public class TestClass + { + [DynamicData(nameof(Data), DynamicDataSourceType.Method)] + [TestMethod] + public void TestMethod(int expected, int actualPlus1) + => Assert.AreEqual(expected, actualPlus1 - 1); + + public static IEnumerable Data() => new[] + { + new object[] { 1, 2 }, + new object[] { 2, 3 }, + }; + } + + } + """, CancellationToken.None); + generatorResult.AssertSuccessfulGeneration(); + + SyntaxTree? testClassTree = generatorResult.GeneratedTrees.FirstOrDefault(r => r.FilePath.EndsWith("TestClass.g.cs", StringComparison.OrdinalIgnoreCase)); + testClassTree.Should().NotBeNull(); + + SourceText testClass = await testClassTree!.GetTextAsync(); + + testClass.Should().ContainSourceCode(""" + GetArguments = static () => { + var data = MyNamespace.TestClass.Data(); + var dataCollection = new ColGen.List>(); + var index = 0; + foreach (var item in data) + { + string uidFragment = MSTF::DynamicDataNameProvider.GetUidFragment(new string[] {"expected", "actualPlus1"}, item, index); + index++; + dataCollection.Add(new(((int) item[0], (int) item[1]), uidFragment)); + } + return dataCollection; + } + """); + } +} diff --git a/test/UnitTests/MSTest.SourceGeneration.UnitTests/Generators/IgnoreAttributeGenerationTests.cs b/test/UnitTests/MSTest.SourceGeneration.UnitTests/Generators/IgnoreAttributeGenerationTests.cs new file mode 100644 index 0000000000..26e69d96c2 --- /dev/null +++ b/test/UnitTests/MSTest.SourceGeneration.UnitTests/Generators/IgnoreAttributeGenerationTests.cs @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using FluentAssertions; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; +using Microsoft.Testing.Framework.SourceGeneration.UnitTests.Helpers; +using Microsoft.Testing.Framework.SourceGeneration.UnitTests.TestUtilities; + +namespace Microsoft.Testing.Framework.SourceGeneration.UnitTests.Generators; + +[TestClass] +public sealed class IgnoreAttributeGenerationTests : TestBase +{ + [TestMethod] + public async Task IgnoreAttribute_OnMethodExcludesTheMethodFromCompilation() + { + GeneratorCompilationResult generatorResult = await GeneratorTester.TestGraph.CompileAndExecuteAsync( + """ + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace MyNamespace + { + [TestClass] + public class TestClass + { + [TestMethod] + public void TestMethod1() { } + + [TestMethod] + [Ignore] + public void IgnoredVoidMethod() { } + + [TestMethod] + [Ignore] + public Task IgnoredTaskMethod() => Task.CompletedTask; + + + [TestMethod] + [Ignore] + public ValueTask IgnoredValueTaskMethod() => ValueTask.CompletedTask; + + [TestMethod] + [Ignore("reason")] + public void IgnoredVoidMethodWithReason() { } + + [TestMethod] + [Ignore("reason")] + public Task IgnoredTaskMethodWithReason() => Task.CompletedTask; + + + [TestMethod] + [Ignore("reason")] + public ValueTask IgnoredValueTaskMethodWithReason() => ValueTask.CompletedTask; + } + } + """, CancellationToken.None); + generatorResult.AssertSuccessfulGeneration(); + + SyntaxTree? testClassTree = generatorResult.GeneratedTrees.FirstOrDefault(r => r.FilePath.EndsWith("TestClass.g.cs", StringComparison.OrdinalIgnoreCase)); + testClassTree.Should().NotBeNull(); + + SourceText testClass = await testClassTree!.GetTextAsync(); + + testClass.Should().ContainSourceCode("""StableUid = "TestAssembly.MyNamespace.TestClass.TestMethod1()","""); + + testClass.Should().NotContain("Ignored", "because none of the ignored methods should be output."); + } +} diff --git a/test/UnitTests/MSTest.SourceGeneration.UnitTests/Generators/StaticMethodGenerationTests.cs b/test/UnitTests/MSTest.SourceGeneration.UnitTests/Generators/StaticMethodGenerationTests.cs new file mode 100644 index 0000000000..8a5a7de839 --- /dev/null +++ b/test/UnitTests/MSTest.SourceGeneration.UnitTests/Generators/StaticMethodGenerationTests.cs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using FluentAssertions; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; +using Microsoft.Testing.Framework.SourceGeneration.UnitTests.Helpers; +using Microsoft.Testing.Framework.SourceGeneration.UnitTests.TestUtilities; + +namespace Microsoft.Testing.Framework.SourceGeneration.UnitTests.Generators; + +[TestClass] +public sealed class StaticMethodGenerationTests +{ + [TestMethod] + public async Task StaticMethods_StaticMethodsWontGenerateTests() + { + GeneratorCompilationResult generatorResult = await GeneratorTester.TestGraph.CompileAndExecuteAsync( + """ + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace MyNamespace + { + [TestClass] + public class TestClass + { + [TestMethod] + public void TestMethod1() { } + + [TestMethod] + public static void StaticTestMethod() { } + + [TestMethod] + public static Task StaticTaskMethod() => Task.CompletedTask; + + + [TestMethod] + public static ValueTask StaticValueTaskMethod() => ValueTask.CompletedTask; + } + } + """, CancellationToken.None); + generatorResult.AssertSuccessfulGeneration(); + + SyntaxTree? testClassTree = generatorResult.GeneratedTrees.FirstOrDefault(r => r.FilePath.EndsWith("TestClass.g.cs", StringComparison.OrdinalIgnoreCase)); + testClassTree.Should().NotBeNull(); + + SourceText testClass = await testClassTree!.GetTextAsync(); + + testClass.Should().ContainSourceCode("""StableUid = "TestAssembly.MyNamespace.TestClass.TestMethod1()","""); + + testClass.Should().NotContain("Static", "because none of the static methods should be output."); + } +} diff --git a/test/UnitTests/MSTest.SourceGeneration.UnitTests/Generators/TestNodesGeneratorTests.cs b/test/UnitTests/MSTest.SourceGeneration.UnitTests/Generators/TestNodesGeneratorTests.cs new file mode 100644 index 0000000000..f9167fd23e --- /dev/null +++ b/test/UnitTests/MSTest.SourceGeneration.UnitTests/Generators/TestNodesGeneratorTests.cs @@ -0,0 +1,1223 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using FluentAssertions; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; +using Microsoft.Testing.Framework.SourceGeneration.UnitTests.Helpers; +using Microsoft.Testing.Framework.SourceGeneration.UnitTests.TestUtilities; + +namespace Microsoft.Testing.Framework.SourceGeneration.UnitTests.Generators; + +[TestClass] +public sealed class TestNodesGeneratorTests : TestBase +{ + [DataRow("class", "public")] + [DataRow("class", "internal")] + [DataRow("record", "public")] + [DataRow("record", "internal")] + [DataRow("record class", "public")] + [DataRow("record class", "internal")] + [TestMethod] + public async Task When_TypeIsMarkedWithTestClass_ItGeneratesAGraphWithAssemblyNamespaceTypeAndMethod(string typeKind, string accessibility) + { + GeneratorCompilationResult generatorResult = await GeneratorTester.TestGraph.CompileAndExecuteAsync( + $$""" + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace MyNamespace + { + [TestClass] + {{accessibility}} {{typeKind}} MyType + { + [TestMethod] + public Task TestMethod() + { + return Task.CompletedTask; + } + } + } + """, CancellationToken.None); + + generatorResult.AssertSuccessfulGeneration(); + generatorResult.GeneratedTrees.Should().HaveCount(3); + + SourceText myTypeSource = await generatorResult.RunResult.GeneratedTrees[0].GetTextAsync(); + myTypeSource.Should().ContainSourceCode(""" + public static readonly MSTF::TestNode TestNode = new MSTF::TestNode + { + StableUid = "TestAssembly.MyNamespace.MyType", + DisplayName = "MyType", + Properties = new Msg::IProperty[1] + { + new Msg::TestFileLocationProperty(@"", new(new(6, -1), new(14, -1))), + }, + Tests = new MSTF::TestNode[] + { + new MSTF::InternalUnsafeAsyncActionTestNode + { + StableUid = "TestAssembly.MyNamespace.MyType.TestMethod()", + DisplayName = "TestMethod", + Properties = new Msg::IProperty[2] + { + new Msg::TestMethodIdentifierProperty( + "TestAssembly, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null", + "MyNamespace", + "MyType", + "TestMethod", + Sys::Array.Empty(), + "System.Threading.Tasks.Task"), + new Msg::TestFileLocationProperty(@"", new(new(9, -1), new(13, -1))), + }, + Body = static async testExecutionContext => + { + var instance = new MyType(); + try + { + await instance.TestMethod(); + } + catch (global::System.Exception ex) + { + testExecutionContext.ReportException(ex, null); + } + }, + }, + }, + }; + """); + + SourceText rootSource = await generatorResult.RunResult.GeneratedTrees[1].GetTextAsync(); + rootSource.Should().ContainSourceCode(""" + MSTF::TestNode root = new MSTF::TestNode + { + StableUid = "TestAssembly", + DisplayName = "TestAssembly", + Properties = Sys::Array.Empty(), + Tests = new MSTF::TestNode[] + { + new MSTF::TestNode + { + StableUid = "TestAssembly.MyNamespace", + DisplayName = "MyNamespace", + Properties = Sys::Array.Empty(), + Tests = namespace1Tests.ToArray(), + }, + }, + }; + """); + } + + [TestMethod] + public async Task When_TypeInheritsABaseClassAndIsParameterless_ItGeneratesATestNode() + { + GeneratorCompilationResult generatorResult = await GeneratorTester.TestGraph.CompileAndExecuteAsync( + $$""" + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace MyNamespace + { + public class MyBaseClass + { + public MyBaseClass(string s) { } + } + + [TestClass] + public class MyType : MyBaseClass + { + public MyType() + : base("hello") + { + } + + [TestMethod] + public Task TestMethod() + { + return Task.CompletedTask; + } + } + } + """, CancellationToken.None); + + generatorResult.AssertSuccessfulGeneration(); + generatorResult.GeneratedTrees.Should().HaveCount(3); + } + + [TestMethod] + public async Task When_TypeInheritsAnAbstractBaseClassAndIsParameterless_ItGeneratesATestNode() + { + GeneratorCompilationResult generatorResult = await GeneratorTester.TestGraph.CompileAndExecuteAsync( + $$""" + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace MyNamespace + { + public abstract class MyBaseClass + { + public MyBaseClass(string s) { } + } + + [TestClass] + public class MyType : MyBaseClass + { + public MyType() + : base("hello") + { + } + + [TestMethod] + public Task TestMethod() + { + return Task.CompletedTask; + } + } + } + """, CancellationToken.None); + + generatorResult.AssertSuccessfulGeneration(); + generatorResult.GeneratedTrees.Should().HaveCount(3); + } + + [DataRow(false)] + [DataRow(true)] + [TestMethod] + public async Task When_TypeInheritsABaseClassWithSomeTestMethodsButBaseIsNotTestClass_OnlyOneTestNodeTypeIsGenerated(bool isBaseClassAbstract) + { + string classModifier = isBaseClassAbstract ? "abstract " : string.Empty; + GeneratorCompilationResult generatorResult = await GeneratorTester.TestGraph.CompileAndExecuteAsync( + $$""" + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace MyNamespace + { + public {{classModifier}}class MyBaseClass + { + public MyBaseClass(string s) { } + + [TestMethod] + public void MyTestMethod() { } + } + + [TestClass] + public class MyType : MyBaseClass + { + public MyType() + : base("hello") + { + } + + [TestMethod] + public Task TestMethod() + { + return Task.CompletedTask; + } + } + } + """, CancellationToken.None); + + generatorResult.AssertSuccessfulGeneration(); + generatorResult.GeneratedTrees.Should().HaveCount(3); + + SourceText myTypeSource = await generatorResult.RunResult.GeneratedTrees[0].GetTextAsync(); + myTypeSource.Should().ContainSourceCode(""" + public static readonly MSTF::TestNode TestNode = new MSTF::TestNode + { + StableUid = "TestAssembly.MyNamespace.MyType", + DisplayName = "MyType", + Properties = new Msg::IProperty[1] + { + new Msg::TestFileLocationProperty(@"", new(new(14, -1), new(27, -1))), + }, + Tests = new MSTF::TestNode[] + { + new MSTF::InternalUnsafeAsyncActionTestNode + { + StableUid = "TestAssembly.MyNamespace.MyType.TestMethod()", + DisplayName = "TestMethod", + Properties = new Msg::IProperty[2] + { + new Msg::TestMethodIdentifierProperty( + "TestAssembly, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null", + "MyNamespace", + "MyType", + "TestMethod", + Sys::Array.Empty(), + "System.Threading.Tasks.Task"), + new Msg::TestFileLocationProperty(@"", new(new(22, -1), new(26, -1))), + }, + Body = static async testExecutionContext => + { + var instance = new MyType(); + try + { + await instance.TestMethod(); + } + catch (global::System.Exception ex) + { + testExecutionContext.ReportException(ex, null); + } + }, + }, + new MSTF::InternalUnsafeActionTestNode + { + StableUid = "TestAssembly.MyNamespace.MyType.MyTestMethod()", + DisplayName = "MyTestMethod", + Properties = new Msg::IProperty[2] + { + new Msg::TestMethodIdentifierProperty( + "TestAssembly, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null", + "MyNamespace", + "MyBaseClass", + "MyTestMethod", + Sys::Array.Empty(), + "System.Void"), + new Msg::TestFileLocationProperty(@"", new(new(10, -1), new(11, -1))), + }, + Body = static testExecutionContext => + { + var instance = new MyType(); + try + { + instance.MyTestMethod(); + } + catch (global::System.Exception ex) + { + testExecutionContext.ReportException(ex, null); + } + }, + }, + }, + }; + """); + } + + [TestMethod] + public async Task When_TypeInheritsABaseTestClassMarkedAsTestClassWithSomeTestMethods_TwoTestNodeTypesAreGenerated() + { + GeneratorCompilationResult generatorResult = await GeneratorTester.TestGraph.CompileAndExecuteAsync( + $$""" + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace MyNamespace + { + [TestClass] + public class MyBaseClass + { + [TestMethod] + public void MyTestMethod() { } + } + + [TestClass] + public class MyType : MyBaseClass + { + [TestMethod] + public Task TestMethod() + { + return Task.CompletedTask; + } + } + } + """, CancellationToken.None); + + generatorResult.AssertSuccessfulGeneration(); + generatorResult.GeneratedTrees.Should().HaveCount(4); + + SourceText myBaseClassSource = await generatorResult.GeneratedTrees[0].GetTextAsync(); + myBaseClassSource.Should().ContainSourceCode(""" + public static readonly MSTF::TestNode TestNode = new MSTF::TestNode + { + StableUid = "TestAssembly.MyNamespace.MyBaseClass", + DisplayName = "MyBaseClass", + Properties = new Msg::IProperty[1] + { + new Msg::TestFileLocationProperty(@"", new(new(6, -1), new(11, -1))), + }, + Tests = new MSTF::TestNode[] + { + new MSTF::InternalUnsafeActionTestNode + { + StableUid = "TestAssembly.MyNamespace.MyBaseClass.MyTestMethod()", + DisplayName = "MyTestMethod", + Properties = new Msg::IProperty[2] + { + new Msg::TestMethodIdentifierProperty( + "TestAssembly, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null", + "MyNamespace", + "MyBaseClass", + "MyTestMethod", + Sys::Array.Empty(), + "System.Void"), + new Msg::TestFileLocationProperty(@"", new(new(9, -1), new(10, -1))), + }, + Body = static testExecutionContext => + { + var instance = new MyBaseClass(); + try + { + instance.MyTestMethod(); + } + catch (global::System.Exception ex) + { + testExecutionContext.ReportException(ex, null); + } + }, + }, + }, + }; + """); + + SourceText myTypeSource = await generatorResult.GeneratedTrees[1].GetTextAsync(); + myTypeSource.Should().ContainSourceCode(""" + public static readonly MSTF::TestNode TestNode = new MSTF::TestNode + { + StableUid = "TestAssembly.MyNamespace.MyType", + DisplayName = "MyType", + Properties = new Msg::IProperty[1] + { + new Msg::TestFileLocationProperty(@"", new(new(13, -1), new(21, -1))), + }, + Tests = new MSTF::TestNode[] + { + new MSTF::InternalUnsafeAsyncActionTestNode + { + StableUid = "TestAssembly.MyNamespace.MyType.TestMethod()", + DisplayName = "TestMethod", + Properties = new Msg::IProperty[2] + { + new Msg::TestMethodIdentifierProperty( + "TestAssembly, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null", + "MyNamespace", + "MyType", + "TestMethod", + Sys::Array.Empty(), + "System.Threading.Tasks.Task"), + new Msg::TestFileLocationProperty(@"", new(new(16, -1), new(20, -1))), + }, + Body = static async testExecutionContext => + { + var instance = new MyType(); + try + { + await instance.TestMethod(); + } + catch (global::System.Exception ex) + { + testExecutionContext.ReportException(ex, null); + } + }, + }, + new MSTF::InternalUnsafeActionTestNode + { + StableUid = "TestAssembly.MyNamespace.MyType.MyTestMethod()", + DisplayName = "MyTestMethod", + Properties = new Msg::IProperty[2] + { + new Msg::TestMethodIdentifierProperty( + "TestAssembly, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null", + "MyNamespace", + "MyBaseClass", + "MyTestMethod", + Sys::Array.Empty(), + "System.Void"), + new Msg::TestFileLocationProperty(@"", new(new(9, -1), new(10, -1))), + }, + Body = static testExecutionContext => + { + var instance = new MyType(); + try + { + instance.MyTestMethod(); + } + catch (global::System.Exception ex) + { + testExecutionContext.ReportException(ex, null); + } + }, + }, + }, + }; + """); + } + + [TestMethod] + public async Task When_TestClassHasAliasForTestNode_ItWontConflictWithIdentifier() + { + // When class has alias for TestNode we should not see conflict in the compiled code. When this is not working correctly + // e.g. when in the class definition you use just TestNode TestNode for the test node property you will see + // "Namespace 'Microsoft.Testing.Framework' contains a definition conflicting with alias 'TestNode', but found False." + // compilation error. + GeneratorCompilationResult generatorResult = await GeneratorTester.TestGraph.CompileAndExecuteAsync( + """ + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + using static System.ConsoleColor; + using TestNode = A.TestNode; + + namespace A + { + public class TestNode + { + } + } + + namespace MyNamespace + { + [TestClass] + public class TestClass + { + [TestMethod] + public Task TestMethod() + { + return Task.CompletedTask; + } + } + } + """, CancellationToken.None); + + generatorResult.AssertSuccessfulGeneration(); + generatorResult.RunResult.GeneratedTrees.Should().HaveCount(3); + + SourceText testClass = await generatorResult.GeneratedTrees[0].GetTextAsync(); + + testClass.Should().ContainSourceCode( + "public static readonly MSTF::TestNode TestNode = new MSTF::TestNode", + "because using short name for TestNode type would conflict with the type alias"); + } + + [TestMethod] + public async Task When_TestClassIsNested_ItGeneratesNodesForIt() + { + GeneratorCompilationResult generatorResult = await GeneratorTester.TestGraph.CompileAndExecuteAsync( + """ + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace MyNamespace + { + [TestClass] + public class TestClass + { + [TestClass] + public class TestSubClass + { + [TestMethod] + public Task TestMethod1() + { + return Task.CompletedTask; + } + } + + [TestMethod] + public Task TestMethod2() + { + return Task.CompletedTask; + } + } + } + """, CancellationToken.None); + + generatorResult.AssertSuccessfulGeneration(); + generatorResult.RunResult.GeneratedTrees.Should().HaveCount(4); + + SyntaxTree? testClassTree = generatorResult.GeneratedTrees.FirstOrDefault(r => r.FilePath.EndsWith("TestSubClass.g.cs", StringComparison.OrdinalIgnoreCase)); + testClassTree.Should().NotBeNull(); + + SourceText testClass = await testClassTree!.GetTextAsync(); + testClass.Should().ContainSourceCode(""" + new MSTF::InternalUnsafeAsyncActionTestNode + { + StableUid = "TestAssembly.MyNamespace.TestClass.TestSubClass.TestMethod1()", + DisplayName = "TestMethod1", + Properties = new Msg::IProperty[2] + { + new Msg::TestMethodIdentifierProperty( + "TestAssembly, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null", + "MyNamespace", + "TestClass.TestSubClass", + "TestMethod1", + Sys::Array.Empty(), + "System.Threading.Tasks.Task"), + new Msg::TestFileLocationProperty(@"", new(new(12, -1), new(16, -1))), + }, + Body = static async testExecutionContext => + { + var instance = new TestClass.TestSubClass(); + try + { + await instance.TestMethod1(); + } + catch (global::System.Exception ex) + { + testExecutionContext.ReportException(ex, null); + } + }, + }, + """); + } + + [TestMethod] + public async Task When_TypeIsInGlobalNamespace_ItGeneratesATestNode() + { + GeneratorCompilationResult generatorResult = await GeneratorTester.TestGraph.CompileAndExecuteAsync( + $$""" + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + [TestClass] + public class MyType + { + [TestMethod] + public Task TestMethod() + { + return Task.CompletedTask; + } + } + """, CancellationToken.None); + + generatorResult.AssertSuccessfulGeneration(); + generatorResult.RunResult.GeneratedTrees.Should().HaveCount(3); + + SourceText myTypeSource = await generatorResult.GeneratedTrees[0].GetTextAsync(); + myTypeSource.Should().ContainSourceCode(""" + public static readonly MSTF::TestNode TestNode = new MSTF::TestNode + { + StableUid = "TestAssembly.MyType", + DisplayName = "MyType", + Properties = new Msg::IProperty[1] + { + new Msg::TestFileLocationProperty(@"", new(new(4, -1), new(12, -1))), + }, + """); + + SourceText rootSource = await generatorResult.GeneratedTrees[1].GetTextAsync(); + rootSource.Should().ContainSourceCode(""" + MSTF::TestNode root = new MSTF::TestNode + { + StableUid = "TestAssembly", + DisplayName = "TestAssembly", + Properties = Sys::Array.Empty(), + Tests = new MSTF::TestNode[] + { + new MSTF::TestNode + { + StableUid = "TestAssembly.", + DisplayName = "", + Properties = Sys::Array.Empty(), + Tests = namespace1Tests.ToArray(), + }, + }, + }; + """); + } + + [TestMethod] + public async Task When_MultipleClassesFromSameNamespace_ItGeneratesASingleNamespaceTestNode() + { + GeneratorCompilationResult generatorResult = await GeneratorTester.TestGraph.CompileAndExecuteAsync( + new[] + { + $$""" + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace MyNamespace + { + [TestClass] + public class MyType1 + { + [TestMethod] + public Task TestMethod() + { + return Task.CompletedTask; + } + } + } + """, + $$""" + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace MyNamespace + { + [TestClass] + public class MyType2 + { + [TestMethod] + public Task TestMethod() + { + return Task.CompletedTask; + } + } + } + """, + }, CancellationToken.None); + + generatorResult.AssertSuccessfulGeneration(); + generatorResult.GeneratedTrees.Should().HaveCount(4); + + SourceText rootSource = await generatorResult.RunResult.GeneratedTrees[2].GetTextAsync(); + rootSource.Should().ContainSourceCode(""" + ColGen::List namespace1Tests = new(); + namespace1Tests.Add(MyNamespace_MyType1.TestNode); + namespace1Tests.Add(MyNamespace_MyType2.TestNode); + + MSTF::TestNode root = new MSTF::TestNode + { + StableUid = "TestAssembly", + DisplayName = "TestAssembly", + Properties = Sys::Array.Empty(), + Tests = new MSTF::TestNode[] + { + new MSTF::TestNode + { + StableUid = "TestAssembly.MyNamespace", + DisplayName = "MyNamespace", + Properties = Sys::Array.Empty(), + Tests = namespace1Tests.ToArray(), + }, + }, + }; + """); + } + + [DataRow("class")] + [DataRow("struct")] + [DataRow("record")] + [DataRow("record struct")] + [DataRow("record class")] + [TestMethod] + [Ignore("Initialize is not supported yet.")] + public async Task When_TypeIsIAsyncInitializable_GeneratedTestNodeIsAsExpected(string typeKind) + { + GeneratorCompilationResult generatorResult = await GeneratorTester.TestGraph.CompileAndExecuteAsync( + $$""" + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace MyNamespace + { + [TestClass] + public {{typeKind}} MyType : IAsyncInitializable + { + [TestMethod] + public Task TestMethod() + { + return Task.CompletedTask; + } + + [TestInitialize] + public Task InitializeAsync(InitializationContext context) + { + return Task.CompletedTask; + } + } + } + """, CancellationToken.None); + + generatorResult.AssertSuccessfulGeneration(); + generatorResult.GeneratedTrees.Should().HaveCount(2); + + SourceText myTypeSource = await generatorResult.RunResult.GeneratedTrees[0].GetTextAsync(); + + // The test node for the type should not have a test node for the InitializeAsync method. + myTypeSource.Should().NotContain("StableUid = \"TestAssembly.MyNamespace.MyType.InitializeAsync\""); + + // The body of the test node for the method should call InitializeAsync before calling the test method. + myTypeSource.Should().ContainSourceCode(""" + Body = static async testExecutionContext => + { + var instance = new MyType(); + try + { + await instance.InitializeAsync(new MSTF::InitializationContext(testExecutionContext.CancellationToken)); + await instance.TestMethod(); + } + catch (global::System.Exception ex) + { + testExecutionContext.ReportException(ex, null); + } + }, + """); + } + + [DataRow("class")] + [DataRow("struct")] + [DataRow("record")] + [DataRow("record struct")] + [DataRow("record class")] + [TestMethod] + [Ignore("Initialize is not supported yet.")] + public async Task When_TypeIsIAsyncCleanable_GeneratedTestNodeIsAsExpected(string typeKind) + { + GeneratorCompilationResult generatorResult = await GeneratorTester.TestGraph.CompileAndExecuteAsync( + $$""" + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace MyNamespace + { + [TestClass] + public {{typeKind}} MyType : IAsyncCleanable + { + public Task TestMethod() + { + return Task.CompletedTask; + } + + public Task CleanupAsync(CleanupContext context) + { + return Task.CompletedTask; + } + } + } + """, CancellationToken.None); + + generatorResult.AssertSuccessfulGeneration(); + generatorResult.GeneratedTrees.Should().HaveCount(2); + + SourceText myTypeSource = await generatorResult.RunResult.GeneratedTrees[0].GetTextAsync(); + + // The test node for the type should not have a test node for the CleanupAsync method. + myTypeSource.Should().NotContain("StableUid = \"TestAssembly.MyNamespace.MyType.CleanupAsync\""); + + // The body of the test node for the method should call CleanupAsync after calling the test method. + myTypeSource.Should().ContainSourceCode(""" + Body = static async testExecutionContext => + { + var instance = new MyType(); + try + { + await instance.TestMethod(); + } + catch (global::System.Exception ex) + { + testExecutionContext.ReportException(ex, null); + } + try + { + await instance.CleanupAsync(new MSTF::CleanupContext(testExecutionContext.CancellationToken)); + } + catch (global::System.Exception ex) + { + testExecutionContext.ReportException(ex, null); + } + }, + """); + } + + [DataRow("class")] + [DataRow("struct")] + [DataRow("record")] + [DataRow("record struct")] + [DataRow("record class")] + [TestMethod] + [Ignore("Initialize is not supported yet.")] + public async Task When_TypeIsIDisposable_GeneratedTestNodeIsAsExpected(string typeKind) + { + GeneratorCompilationResult generatorResult = await GeneratorTester.TestGraph.CompileAndExecuteAsync( + $$""" + using System; + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace MyNamespace + { + [TestClass] + public {{typeKind}} MyType : IDisposable + { + public Task TestMethod() + { + return Task.CompletedTask; + } + + public void Dispose() + { + } + } + } + """, CancellationToken.None); + + generatorResult.AssertSuccessfulGeneration(); + generatorResult.GeneratedTrees.Should().HaveCount(2); + + SourceText myTypeSource = await generatorResult.RunResult.GeneratedTrees[0].GetTextAsync(); + + // The test node for the type should not have a test node for the Dispose method. + myTypeSource.Should().NotContain("StableUid = \"TestAssembly.MyNamespace.MyType.Dispose\""); + + // The body of the test node for the method should call Dispose after calling the test method. + myTypeSource.Should().ContainSourceCode(""" + Body = static async testExecutionContext => + { + using var instance = new MyType(); + try + { + await instance.TestMethod(); + } + catch (global::System.Exception ex) + { + testExecutionContext.ReportException(ex, null); + } + }, + """); + } + + [DataRow("class")] + [DataRow("struct")] + [DataRow("record")] + [DataRow("record struct")] + [DataRow("record class")] + [TestMethod] + [Ignore("Initialize is not supported yet.")] + public async Task When_TypeIsIAsyncDisposable_GeneratedTestNodeIsAsExpected(string typeKind) + { + GeneratorCompilationResult generatorResult = await GeneratorTester.TestGraph.CompileAndExecuteAsync( + $$""" + using System; + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace MyNamespace + { + [TestClass] + public {{typeKind}} MyType : IAsyncDisposable + { + public Task TestMethod() + { + return Task.CompletedTask; + } + + public ValueTask DisposeAsync() + { + return ValueTask.CompletedTask; + } + } + } + """, CancellationToken.None); + + generatorResult.AssertSuccessfulGeneration(); + generatorResult.GeneratedTrees.Should().HaveCount(2); + + SourceText myTypeSource = await generatorResult.RunResult.GeneratedTrees[0].GetTextAsync(); + + // The test node for the type should not have a test node for the DisposeAsync method. + myTypeSource.Should().NotContain("StableUid = \"TestAssembly.MyNamespace.MyType.DisposeAsync\""); + + // The body of the test node for the method should call DisposeAsync after calling the test method. + myTypeSource.Should().ContainSourceCode(""" + Body = static async testExecutionContext => + { + await using var instance = new MyType(); + try + { + await instance.TestMethod(); + } + catch (global::System.Exception ex) + { + testExecutionContext.ReportException(ex, null); + } + }, + """); + } + + [DataRow("class")] + [DataRow("struct")] + [DataRow("record")] + [DataRow("record struct")] + [DataRow("record class")] + [TestMethod] + [Ignore("Initialize is not supported yet.")] + public async Task When_TypeIsIAsyncDisposableAndIDisposable_GeneratedTestNodeIsAsExpected(string typeKind) + { + GeneratorCompilationResult generatorResult = await GeneratorTester.TestGraph.CompileAndExecuteAsync( + $$""" + using System; + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace MyNamespace + { + [TestClass] + public {{typeKind}} MyType : IAsyncDisposable, IDisposable + { + public Task TestMethod() + { + return Task.CompletedTask; + } + + public ValueTask DisposeAsync() + { + return ValueTask.CompletedTask; + } + + public void Dispose() + { + } + } + } + """, CancellationToken.None); + + generatorResult.AssertSuccessfulGeneration(); + generatorResult.GeneratedTrees.Should().HaveCount(2); + + SourceText myTypeSource = await generatorResult.RunResult.GeneratedTrees[0].GetTextAsync(); + + // The body of the test node for the method should call only DisposeAsync after calling the test method. + myTypeSource.Should().ContainSourceCode(""" + Body = static async testExecutionContext => + { + await using var instance = new MyType(); + try + { + await instance.TestMethod(); + } + catch (global::System.Exception ex) + { + testExecutionContext.ReportException(ex, null); + } + }, + """); + } + + [TestMethod] + [Ignore("Initialize is not supported yet.")] + public async Task When_MethodIsNotAsyncButTypeUsesAsync_GeneratedTestNodeIsAsync() + { + GeneratorCompilationResult generatorResult = await GeneratorTester.TestGraph.CompileAndExecuteAsync( + $$""" + using System; + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace MyNamespace + { + [TestClass] + public class MyClass1 : IAsyncDisposable + { + public void TestMethod() + { + } + + public ValueTask DisposeAsync() + { + return ValueTask.CompletedTask; + } + } + + [TestClass] + public class MyClass2 : IAsyncInitializable + { + public void TestMethod() + { + } + + public Task InitializeAsync(InitializationContext context) + { + return Task.CompletedTask; + } + } + + [TestClass] + public class MyClass3 : IAsyncCleanable + { + public void TestMethod() + { + } + + public Task CleanupAsync(CleanupContext context) + { + return Task.CompletedTask; + } + } + } + """, CancellationToken.None); + + generatorResult.AssertSuccessfulGeneration(); + generatorResult.GeneratedTrees.Should().HaveCount(4); + + SourceText myClass1Source = await generatorResult.RunResult.GeneratedTrees[0].GetTextAsync(); + myClass1Source.Should().ContainSourceCode(""" + new MSTF::InternalUnsafeAsyncActionTestNode + { + StableUid = "TestAssembly.MyNamespace.MyClass1.TestMethod()", + """); + + SourceText myClass2Source = await generatorResult.RunResult.GeneratedTrees[1].GetTextAsync(); + myClass2Source.Should().ContainSourceCode(""" + new MSTF::InternalUnsafeAsyncActionTestNode + { + StableUid = "TestAssembly.MyNamespace.MyClass2.TestMethod()", + """); + + SourceText myClass3Source = await generatorResult.RunResult.GeneratedTrees[2].GetTextAsync(); + myClass3Source.Should().ContainSourceCode(""" + new MSTF::InternalUnsafeAsyncActionTestNode + { + StableUid = "TestAssembly.MyNamespace.MyClass3.TestMethod()", + """); + } + + [TestMethod] + public async Task When_MethodIsObsolete_WrapMethodCallWithPragma() + { + GeneratorCompilationResult generatorResult = await GeneratorTester.TestGraph.CompileAndExecuteAsync( + $$""" + using System; + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace MyNamespace + { + [TestClass] + public class MyType + { + [Obsolete] + [TestMethod] + public Task TestMethod1() + { + return Task.CompletedTask; + } + + [Obsolete("This is obsolete with message")] + [TestMethod] + public Task TestMethod2() + { + return Task.CompletedTask; + } + + [Obsolete("This is obsolete with message", false)] + [TestMethod] + public Task TestMethod3() + { + return Task.CompletedTask; + } + + [Obsolete("This is obsolete with message", true)] + [TestMethod] + public Task TestMethod4() + { + return Task.CompletedTask; + } + } + } + """, CancellationToken.None); + + generatorResult.AssertFailedGeneration("*error CS0619: 'MyType.TestMethod4()' is obsolete*"); + generatorResult.GeneratedTrees.Should().HaveCount(3); + + SourceText myTypeSource = await generatorResult.RunResult.GeneratedTrees[0].GetTextAsync(); + myTypeSource.Should().ContainSourceCode(""" + #pragma warning disable CS0612 // Type or member is obsolete + await instance.TestMethod1(); + #pragma warning restore CS0612 // Type or member is obsolete + """); + + myTypeSource.Should().ContainSourceCode(""" + #pragma warning disable CS0618 // Type or member is obsolete + await instance.TestMethod2(); + #pragma warning restore CS0618 // Type or member is obsolete + """); + + myTypeSource.Should().ContainSourceCode(""" + #pragma warning disable CS0618 // Type or member is obsolete + await instance.TestMethod3(); + #pragma warning restore CS0618 // Type or member is obsolete + """); + } + + [DataRow("1ab", "_1ab")] + [DataRow("a-b", "a_b")] + [DataRow("a.", "a_")] + [DataRow("!@#$%^&*()_+-=", "______________")] + [TestMethod] + + // Disabled for potential bug in templating (where escaping code is copied from) + // https://github.com/dotnet/templating/issues/7200 + // [DataRow("a..b", "a..b")] + // [DataRow("a...b", "a...b")] + public void GenerateValidNamespaceName_WithGivenAssemblyName_ReturnsExpectedNamespaceName(string assemblyName, string expectedNamespaceName) + => Assert.AreEqual(expectedNamespaceName, TestNodesGenerator.ToSafeNamespace(assemblyName)); + + [TestMethod] + public async Task When_APartialTypeIsMarkedWithTestClass_ItGeneratesAGraphWithAssemblyNamespaceTypeAndMethods() + { + GeneratorCompilationResult generatorResult = await GeneratorTester.TestGraph.CompileAndExecuteAsync( + new string[] + { + $$""" + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace MyNamespace + { + [TestClass] + public partial class MyType + { + public MyType(int a) { } + + [TestMethod] + public Task TestMethod1() + { + return Task.CompletedTask; + } + } + } + """, + $$""" + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace MyNamespace + { + // Defining [TestClass] twice would fail + // the source gen with Duplicate source MyNamespace.MyType.g.cs + // but if we fix that problem it will subsequently fail with + // duplicate attribute [TestClass]. + public partial class MyType + { + public MyType() {} + + [TestMethod] + public Task TestMethod2() + { + return Task.CompletedTask; + } + } + } + """, + }, CancellationToken.None); + + generatorResult.AssertSuccessfulGeneration(); + generatorResult.GeneratedTrees.Should().HaveCount(3); + + SourceText myTypeSource = await generatorResult.RunResult.GeneratedTrees[0].GetTextAsync(); + myTypeSource.Should().ContainSourceCode(""" + public static class MyNamespace_MyType + { + public static readonly MSTF::TestNode TestNode = new MSTF::TestNode + { + StableUid = "TestAssembly.MyNamespace.MyType", + DisplayName = "MyType", + Properties = new Msg::IProperty[2] + { + new Msg::TestFileLocationProperty(@"", new(new(6, -1), new(16, -1))), + new Msg::TestFileLocationProperty(@"", new(new(10, -1), new(19, -1))), + }, + Tests = new MSTF::TestNode[] + { + """); + + myTypeSource.Should().ContainSourceCode(""" + StableUid = "TestAssembly.MyNamespace.MyType.TestMethod1()", + """); + + myTypeSource.Should().ContainSourceCode(""" + StableUid = "TestAssembly.MyNamespace.MyType.TestMethod2()", + """); + } +} diff --git a/test/UnitTests/MSTest.SourceGeneration.UnitTests/Helpers/ConstantsTests.cs b/test/UnitTests/MSTest.SourceGeneration.UnitTests/Helpers/ConstantsTests.cs new file mode 100644 index 0000000000..a579ec298c --- /dev/null +++ b/test/UnitTests/MSTest.SourceGeneration.UnitTests/Helpers/ConstantsTests.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using FluentAssertions; + +namespace Microsoft.Testing.Framework.SourceGeneration.UnitTests; + +[TestClass] +public class ConstantsTests : TestBase +{ + [TestMethod] + public void NewLine_IsWindowsLineReturn() => Constants.NewLine.Should().Be("\r\n"); +} diff --git a/test/UnitTests/MSTest.SourceGeneration.UnitTests/Helpers/GeneratorCompilationResultHelpers.cs b/test/UnitTests/MSTest.SourceGeneration.UnitTests/Helpers/GeneratorCompilationResultHelpers.cs new file mode 100644 index 0000000000..6bd800f900 --- /dev/null +++ b/test/UnitTests/MSTest.SourceGeneration.UnitTests/Helpers/GeneratorCompilationResultHelpers.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 FluentAssertions; + +using Microsoft.CodeAnalysis; +using Microsoft.Testing.Framework.SourceGeneration.UnitTests.Helpers; +using Microsoft.Testing.Framework.SourceGeneration.UnitTests.TestUtilities; + +namespace Microsoft.Testing.Framework.SourceGeneration.UnitTests.Helpers; + +internal static class GeneratorCompilationResultHelpers +{ + public static GeneratorCompilationResult AssertSuccessfulGeneration(this GeneratorCompilationResult result) + { + result.EmitResult.Success.Should().BeTrue("compilation should have been successful.\n" + + $"Diagnostics: {result.EmitResult.Diagnostics.Length}\n" + + $"Code:\n{result.FailingGeneratedCode}"); + result.TimingInfo.ElapsedTime.Should().BeGreaterThan(TimeSpan.Zero); + result.EmitResult.Diagnostics.Where(d => d.Severity is DiagnosticSeverity.Warning or DiagnosticSeverity.Error).Should().BeEmpty(); + result.RunResult.Diagnostics.Should().BeEmpty(); + return result; + } + + public static GeneratorCompilationResult AssertFailedGeneration(this GeneratorCompilationResult result, params string[] diagnostics) + { + result.EmitResult.Success.Should().BeFalse(); + result.TimingInfo.ElapsedTime.Should().BeGreaterThan(TimeSpan.Zero); + result.RunResult.Diagnostics.Should().BeEmpty(); + result.EmitResult.Diagnostics.Should().HaveSameCount(diagnostics); + + for (int i = 0; i < diagnostics.Length; i++) + { + result.EmitResult.Diagnostics.Select(d => d.ToString()).Should().ContainMatch(diagnostics[i]); + } + + return result; + } +} diff --git a/test/UnitTests/MSTest.SourceGeneration.UnitTests/Helpers/MinimalTestRunner.cs b/test/UnitTests/MSTest.SourceGeneration.UnitTests/Helpers/MinimalTestRunner.cs new file mode 100644 index 0000000000..28c3092327 --- /dev/null +++ b/test/UnitTests/MSTest.SourceGeneration.UnitTests/Helpers/MinimalTestRunner.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; + +/// +/// Discovers and runs tests using the MSTest attributes, so we can run tests even when we completely break or delete the real MSTest engine. +/// +internal sealed class MinimalTestRunner +{ + public static async Task RunAllAsync(string? testNameContainsFilter = null) + { +#pragma warning disable IL2026 // Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code + IEnumerable classes = Assembly.GetExecutingAssembly().GetTypes().Where(c => c.IsPublic); +#pragma warning restore IL2026 // Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code + object[][] emptyRow = new[] { Array.Empty() }; + + int total = 0; + int failed = 0; + int passed = 0; + foreach (Type? c in classes) + { + IList attributes = c.GetCustomAttributesData(); + + if (!attributes.Any(a => a.AttributeType == typeof(TestClassAttribute))) + { + continue; + } + + if (attributes.Any(a => a.AttributeType == typeof(IgnoreAttribute))) + { + Console.WriteLine($"Class {c.Name} is ignored."); + continue; + } + +#pragma warning disable IL2075 // 'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The return value of the source method does not have matching annotations. + foreach (MethodInfo m in c.GetMethods()) + { + if (!string.IsNullOrWhiteSpace(testNameContainsFilter)) + { +#pragma warning disable CA1304 // Specify CultureInfo +#pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons +#pragma warning disable CA1311 // Specify a culture or use an invariant version + if (!m.Name!.ToLower().Contains(testNameContainsFilter.ToLower())) + { + continue; + } +#pragma warning restore CA1311 // Specify a culture or use an invariant version +#pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons +#pragma warning restore CA1304 // Specify CultureInfo + } + + IList methodAttributes = m.GetCustomAttributesData(); + if (!methodAttributes.Any(a => a.AttributeType == typeof(TestMethodAttribute))) + { + continue; + } + + if (methodAttributes.Any(a => a.AttributeType == typeof(IgnoreAttribute))) + { + Console.WriteLine($"Method {c.Name} is ignored."); + continue; + } + + object?[][]? rows = null; + if (methodAttributes.Any(a => a.AttributeType == typeof(DataRowAttribute))) + { + rows = methodAttributes + .Where(a => a.AttributeType == typeof(DataRowAttribute)) + .SelectMany(a => a.ConstructorArguments.Select(arg => + { + // An object that represents the value of the argument or element, or a generic ReadOnlyCollection of CustomAttributeTypedArgument objects that represent the values of an array-type argument. + // https://learn.microsoft.com/en-us/dotnet/api/system.reflection.customattributetypedargument.value?view=net-8.0#property-value +#pragma warning disable IDE0046 // Convert to conditional expression + if (arg.Value is IReadOnlyCollection argumentCollection) + { + return argumentCollection.Select(argv => argv.Value).ToArray(); + } + else + { + return [arg.Value]; + } +#pragma warning restore IDE0046 // Convert to conditional expression + })) + .ToArray(); + } + + foreach (object?[]? row in rows ?? emptyRow) + { + ConsoleColor fg = Console.ForegroundColor; + try + { + total++; +#pragma warning disable IL2072 // Target parameter argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The return value of the source method does not have matching annotations. + object? classInstance = Activator.CreateInstance(c); +#pragma warning restore IL2072 // Target parameter argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The return value of the source method does not have matching annotations. + object? result = m.Invoke(classInstance, row); + if (result is Task task) + { + await task; + } + else if (result is ValueTask valueTask) + { + await valueTask; + } + + passed++; + Console.ForegroundColor = ConsoleColor.Green; + Console.WriteLine($"Passed {c.Name}.{m.Name}"); + } + catch (TargetInvocationException ex) + { + failed++; + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($"Failed {c.Name}.{m.Name}:\n{ex.InnerException}\n{ex.InnerException!.StackTrace}\n"); + } + catch (Exception ex) + { + failed++; + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($"Failed {c.Name}.{m.Name}:\n{ex}\n{ex.StackTrace}\n"); + } + finally + { + Console.ForegroundColor = fg; + } + } + } +#pragma warning restore IL2075 // 'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The return value of the source method does not have matching annotations. + } + + Console.WriteLine($"{(failed != 0 ? "failed" : "passed")}! - failed: {failed}, passed: {passed}, total: {total}"); + + return failed == 0 ? 0 : 1; + } +} diff --git a/test/UnitTests/MSTest.SourceGeneration.UnitTests/Helpers/SourceCodeAssertionExtensions.cs b/test/UnitTests/MSTest.SourceGeneration.UnitTests/Helpers/SourceCodeAssertionExtensions.cs new file mode 100644 index 0000000000..6de2831088 --- /dev/null +++ b/test/UnitTests/MSTest.SourceGeneration.UnitTests/Helpers/SourceCodeAssertionExtensions.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using FluentAssertions; +using FluentAssertions.Primitives; + +using Microsoft.CodeAnalysis.Text; + +namespace Microsoft.Testing.Framework.SourceGeneration.UnitTests.Helpers; + +internal static class SourceCodeAssertionExtensions +{ + public static SourceCodeAssertions Should(this SourceText sourceText) => new(sourceText.ToString()); + + public static AndConstraint ContainSourceCode(this SourceCodeAssertions parent, string expectedSourceCode) + { + AndConstraint assertions = new SourceCodeAssertions(parent.Subject).ContainSourceCode(expectedSourceCode); + + return assertions; + } + + public static AndConstraint ContainSourceCode(this StringAssertions parent, string expectedSourceCode) + { + AndConstraint assertions = new SourceCodeAssertions(parent.Subject).ContainSourceCode(expectedSourceCode); + + return assertions; + } +} diff --git a/test/UnitTests/MSTest.SourceGeneration.UnitTests/Helpers/SourceCodeAssertions.cs b/test/UnitTests/MSTest.SourceGeneration.UnitTests/Helpers/SourceCodeAssertions.cs new file mode 100644 index 0000000000..d01d8427f6 --- /dev/null +++ b/test/UnitTests/MSTest.SourceGeneration.UnitTests/Helpers/SourceCodeAssertions.cs @@ -0,0 +1,83 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using FluentAssertions; +using FluentAssertions.Execution; +using FluentAssertions.Primitives; + +namespace Microsoft.Testing.Framework.SourceGeneration.UnitTests.Helpers; + +internal sealed class SourceCodeAssertions : StringAssertions +{ + public SourceCodeAssertions(string value) + : base(value) + { + } + + public AndConstraint ContainSourceCode(string expectedSourceCode, string because = "", params object[] becauseArgs) + { + if (string.IsNullOrEmpty(expectedSourceCode)) + { + throw new ArgumentException("Cannot assert string containment against or Empty source code.", nameof(expectedSourceCode)); + } + + bool onlyDifferInWhitespace = false; + try + { + Subject.ShowReducedWhitespace().Should().Contain(expectedSourceCode.ShowReducedWhitespace()); + onlyDifferInWhitespace = true; + } + catch + { + } + + string subMessage = "Expected \n{context:string}\n{0}\n to contain\n{1}\n{reason}."; + string message = onlyDifferInWhitespace + ? $"WHITESPACE ONLY DIFFERENCE!\n\n{subMessage}" + : subMessage; + + string actual = Subject.ShowWhitespace(); + string expected = expectedSourceCode.ShowWhitespace(); + Execute.Assertion + .ForCondition(Contains(actual, expected, StringComparison.Ordinal)) + .BecauseOf(because, becauseArgs) + .FailWith(message, actual, expected); + + return new AndConstraint(this); + } + + public AndConstraint BeSourceCode(string expectedSourceCode, string because = "", params object[] becauseArgs) + { + if (string.IsNullOrEmpty(expectedSourceCode)) + { + throw new ArgumentException("Cannot assert string equality against or Empty source code.", nameof(expectedSourceCode)); + } + + bool onlyDifferInWhitespace = false; + try + { + Subject.ShowReducedWhitespace().Should().Be(expectedSourceCode.ShowReducedWhitespace()); + onlyDifferInWhitespace = true; + } + catch + { + } + + string subMessage = "Expected \n{context:string}\n{0}\n to match\n{1}\n{reason}."; + string message = onlyDifferInWhitespace + ? $"WHITESPACE ONLY DIFFERENCE!\n\n{subMessage}" + : subMessage; + + string actual = Subject.ShowWhitespace(); + string expected = expectedSourceCode.ShowWhitespace(); + Execute.Assertion + .ForCondition(string.Equals(actual, expected, StringComparison.Ordinal)) + .BecauseOf(because, becauseArgs) + .FailWith(message, actual, expected); + + return new AndConstraint(this); + } + + private static bool Contains(string actual, string expected, StringComparison comparison) + => (actual ?? string.Empty).Contains(expected ?? string.Empty, comparison); +} diff --git a/test/UnitTests/MSTest.SourceGeneration.UnitTests/Helpers/SyntaxExtensions.cs b/test/UnitTests/MSTest.SourceGeneration.UnitTests/Helpers/SyntaxExtensions.cs new file mode 100644 index 0000000000..c87ed621a0 --- /dev/null +++ b/test/UnitTests/MSTest.SourceGeneration.UnitTests/Helpers/SyntaxExtensions.cs @@ -0,0 +1,52 @@ +// 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.CodeAnalysis.Text; + +namespace Microsoft.Testing.Framework.SourceGeneration.UnitTests.Helpers; + +internal static class SyntaxExtensions +{ + public static string ShowWhitespace(this SourceText text) => text.ToString().ShowWhitespace(); + + /// + /// Show spaces and tabs as '·' and '→', replace "\r", but keep newlines, so the resulting text + /// is still formatted it would be in a file but the lines are not OS specific and the whitespace is easy to see. + /// + public static string ShowWhitespace(this string text) + { + if (text.Contains('·') || text.Contains('→')) + { + throw new ArgumentException("Provided text contains '·' or '→' characters, " + + $"which {nameof(ShowWhitespace)} uses to show whitespace. " + + "Did you copy paste it from the test result and forgot to remove those replacements?"); + } + + IDictionary map = new Dictionary() + { + { "\r", string.Empty }, + + // Tabs output just 1 '→' on purpose, to break the code layout and be easier to spot. + // We don't want tabs in our code. + { "\t", "→" }, + { " ", "·" }, + }; + + var regex = new Regex(string.Join("|", map.Keys)); + return regex.Replace(text, m => map[m.Value]); + } + + /// + /// Remove every doubled space, which gives you text that still spans multiple lines + /// but the amount of whitespace is greatly reduced. This helps when you are not sure if your content + /// is incorrect or it is just whitespace that is incorrect. + /// + public static string ShowReducedWhitespace(this SourceText text) => ShowReducedWhitespace(text.ToString()); + + /// + /// Remove every doubled space, which gives you text that still spans multiple lines + /// but the amount of whitespace is greatly reduced. This helps when you are not sure if your content + /// is incorrect or it is just whitespace that is incorrect. + /// + public static string ShowReducedWhitespace(this string text) => Regex.Replace(text, " {2,}", string.Empty).ShowWhitespace(); +} diff --git a/test/UnitTests/MSTest.SourceGeneration.UnitTests/MSTest.SourceGeneration.UnitTests.csproj b/test/UnitTests/MSTest.SourceGeneration.UnitTests/MSTest.SourceGeneration.UnitTests.csproj new file mode 100644 index 0000000000..208e87d90f --- /dev/null +++ b/test/UnitTests/MSTest.SourceGeneration.UnitTests/MSTest.SourceGeneration.UnitTests.csproj @@ -0,0 +1,32 @@ + + + + + net6.0 + $(NoWarn);NU1701 + false + true + Microsoft.Testing.Framework.SourceGeneration.UnitTests + + + + + + + + + + + PreserveNewest + + + + + + + Analyzer + true + + + + diff --git a/test/UnitTests/MSTest.SourceGeneration.UnitTests/MSTest.SourceGeneration.UnitTests.launcher.config.json b/test/UnitTests/MSTest.SourceGeneration.UnitTests/MSTest.SourceGeneration.UnitTests.launcher.config.json new file mode 100644 index 0000000000..0e1704afa1 --- /dev/null +++ b/test/UnitTests/MSTest.SourceGeneration.UnitTests/MSTest.SourceGeneration.UnitTests.launcher.config.json @@ -0,0 +1,3 @@ +{ + "program": "MSTest.SourceGeneration.UnitTests.exe" +} diff --git a/test/UnitTests/MSTest.SourceGeneration.UnitTests/MSTest.SourceGeneration.UnitTests.testingplatformconfig.json b/test/UnitTests/MSTest.SourceGeneration.UnitTests/MSTest.SourceGeneration.UnitTests.testingplatformconfig.json new file mode 100644 index 0000000000..371fa66d5b --- /dev/null +++ b/test/UnitTests/MSTest.SourceGeneration.UnitTests/MSTest.SourceGeneration.UnitTests.testingplatformconfig.json @@ -0,0 +1,16 @@ +{ + "testingplatform": { + "telemetry": { + "isDevelopmentRepository": true + }, + "exitProcessOnUnhandledException": true, + "testHostControllersManager": { + "singleConnectionNamedPipeServer": { + "waitConnectionTimeoutSeconds": 90 + }, + "namedPipeClient": { + "connectTimeoutSeconds": 90 + } + } + } +} diff --git a/test/UnitTests/MSTest.SourceGeneration.UnitTests/MSTest.SourceGeneration/Microsoft.Testing.Framework.SourceGeneration.TestNodesGenerator/Microsoft.Testing.Framework.Source b/test/UnitTests/MSTest.SourceGeneration.UnitTests/MSTest.SourceGeneration/Microsoft.Testing.Framework.SourceGeneration.TestNodesGenerator/Microsoft.Testing.Framework.Source new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/UnitTests/MSTest.SourceGeneration.UnitTests/ObjectModels/InlineTestMethodArgumentsInfoTests.cs b/test/UnitTests/MSTest.SourceGeneration.UnitTests/ObjectModels/InlineTestMethodArgumentsInfoTests.cs new file mode 100644 index 0000000000..9c34e23745 --- /dev/null +++ b/test/UnitTests/MSTest.SourceGeneration.UnitTests/ObjectModels/InlineTestMethodArgumentsInfoTests.cs @@ -0,0 +1,30 @@ +// 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.Framework.SourceGeneration.ObjectModels; + +namespace Microsoft.Testing.Framework.SourceGeneration.UnitTests; + +[TestClass] +public sealed class InlineTestMethodArgumentsInfoTests : TestBase +{ + [DataRow("a", "a")] + [DataRow("a, b", "a, b")] + [DataRow("\"ok\"", "\\\"ok\\\"")] + [DataRow("\"", "\\\"")] + [DataRow("\\", "\\")] + [DataRow("\\\\", "\\\\")] + [DataRow("\\\"", "\\\"")] + [TestMethod] + public void EscapeArgument_ProducesCorrectString(string value, string expectedEscapedValue) + { + // Arrange + StringBuilder stringBuilder = new(); + + // Act + DataRowTestMethodArgumentsInfo.EscapeArgument(value, stringBuilder); + + // Assert + Assert.AreEqual(expectedEscapedValue, stringBuilder.ToString()); + } +} diff --git a/test/UnitTests/MSTest.SourceGeneration.UnitTests/Program.cs b/test/UnitTests/MSTest.SourceGeneration.UnitTests/Program.cs new file mode 100644 index 0000000000..79206b730e --- /dev/null +++ b/test/UnitTests/MSTest.SourceGeneration.UnitTests/Program.cs @@ -0,0 +1,29 @@ +// 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; + +using ExecutionScope = Microsoft.VisualStudio.TestTools.UnitTesting.ExecutionScope; + +[assembly: Parallelize(Scope = ExecutionScope.MethodLevel, Workers = 0)] +[assembly: ClassCleanupExecution(ClassCleanupBehavior.EndOfClass)] + +ITestApplicationBuilder builder = await TestApplication.CreateBuilderAsync(args); +builder.AddMSTest(() => [Assembly.GetEntryAssembly()!]); + +#if ENABLE_CODECOVERAGE +builder.AddCodeCoverageProvider(); +#endif +builder.AddHangDumpProvider(); +builder.AddCrashDumpProvider(ignoreIfNotSupported: true); +builder.AddTrxReportProvider(); +builder.AddAppInsightsTelemetryProvider(); + +// Custom suite tools +CompositeExtensionFactory slowestTestCompositeServiceFactory + = new(_ => new SlowestTestsConsumer()); +builder.TestHost.AddDataConsumer(slowestTestCompositeServiceFactory); +builder.TestHost.AddTestSessionLifetimeHandle(slowestTestCompositeServiceFactory); + +using ITestApplication app = await builder.BuildAsync(); +return await app.RunAsync(); diff --git a/test/UnitTests/MSTest.SourceGeneration.UnitTests/Properties/launchSettings.json b/test/UnitTests/MSTest.SourceGeneration.UnitTests/Properties/launchSettings.json new file mode 100644 index 0000000000..b0f74f2d64 --- /dev/null +++ b/test/UnitTests/MSTest.SourceGeneration.UnitTests/Properties/launchSettings.json @@ -0,0 +1,8 @@ +{ + "profiles": { + "MSTest.SourceGeneration.UnitTests": { + "commandName": "Project", + "commandLineArgs": "--treenode-filter /*/*/*/**" + } + } +} diff --git a/test/UnitTests/MSTest.SourceGeneration.UnitTests/TestBase.cs b/test/UnitTests/MSTest.SourceGeneration.UnitTests/TestBase.cs new file mode 100644 index 0000000000..fcc6fa6db5 --- /dev/null +++ b/test/UnitTests/MSTest.SourceGeneration.UnitTests/TestBase.cs @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Microsoft.Testing.Framework.SourceGeneration.UnitTests; + +/// +/// Empty test base, because TestInfrastructure project depends on Testing.Framework, and we cannot use that. +/// +public abstract class TestBase +{ +} diff --git a/test/UnitTests/MSTest.SourceGeneration.UnitTests/TestUtilities/CSharpCodeFixVerifier.cs b/test/UnitTests/MSTest.SourceGeneration.UnitTests/TestUtilities/CSharpCodeFixVerifier.cs new file mode 100644 index 0000000000..4d606aedc6 --- /dev/null +++ b/test/UnitTests/MSTest.SourceGeneration.UnitTests/TestUtilities/CSharpCodeFixVerifier.cs @@ -0,0 +1,29 @@ +// 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.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp.Testing; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Testing; + +namespace Microsoft.Testing.Framework.SourceGeneration.UnitTests.TestUtilities; + +internal static class CSharpCodeFixVerifier + where TAnalyzer : DiagnosticAnalyzer, new() + where TCodeFix : CodeFixProvider, new() +{ + public static async Task VerifyAnalyzerAsync(string source, params DiagnosticResult[] expected) + { + var test = new Test { TestCode = source }; + test.ExpectedDiagnostics.AddRange(expected); + await test.RunAsync(); + } + + public static DiagnosticResult Diagnostic(DiagnosticDescriptor descriptor) + => CSharpCodeFixVerifier.Diagnostic(descriptor); + + public sealed class Test : CSharpCodeFixTest + { + } +} diff --git a/test/UnitTests/MSTest.SourceGeneration.UnitTests/TestUtilities/GeneratorCompilationResult.cs b/test/UnitTests/MSTest.SourceGeneration.UnitTests/TestUtilities/GeneratorCompilationResult.cs new file mode 100644 index 0000000000..7fa121a363 --- /dev/null +++ b/test/UnitTests/MSTest.SourceGeneration.UnitTests/TestUtilities/GeneratorCompilationResult.cs @@ -0,0 +1,23 @@ +// 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.Collections.Immutable; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Emit; + +namespace Microsoft.Testing.Framework.SourceGeneration.UnitTests.TestUtilities; + +internal sealed class GeneratorCompilationResult(GeneratorDriverRunResult runResult, GeneratorDriverTimingInfo timingInfo, + EmitResult emitResult, string? failingGeneratedCode) +{ + public ImmutableArray GeneratedTrees => RunResult.GeneratedTrees; + + public GeneratorDriverRunResult RunResult { get; } = runResult; + + public GeneratorDriverTimingInfo TimingInfo { get; } = timingInfo; + + public EmitResult EmitResult { get; } = emitResult; + + public string? FailingGeneratedCode { get; } = failingGeneratedCode; +} diff --git a/test/UnitTests/MSTest.SourceGeneration.UnitTests/TestUtilities/GeneratorTester.cs b/test/UnitTests/MSTest.SourceGeneration.UnitTests/TestUtilities/GeneratorTester.cs new file mode 100644 index 0000000000..83f1290dcc --- /dev/null +++ b/test/UnitTests/MSTest.SourceGeneration.UnitTests/TestUtilities/GeneratorTester.cs @@ -0,0 +1,173 @@ +// 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.Collections.Immutable; + +using FluentAssertions; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Emit; +using Microsoft.CodeAnalysis.Testing; +using Microsoft.Testing.Extensions; +using Microsoft.Testing.Extensions.TrxReport.Abstractions; +using Microsoft.Testing.Platform.Extensions.Messages; + +namespace Microsoft.Testing.Framework.SourceGeneration.UnitTests.TestUtilities; + +internal sealed class GeneratorTester +{ + private readonly Func _incrementalGeneratorFactory; + private readonly string[] _additionalReferences; + private static readonly SemaphoreSlim Lock = new(1); + + public GeneratorTester(Func incrementalGeneratorFactory, string[] additionalReferences) + { + _incrementalGeneratorFactory = incrementalGeneratorFactory; + _additionalReferences = additionalReferences; + } + + public static GeneratorTester TestGraph { get; } = + new( + () => new TestNodesGenerator(), + new[] + { + // Microsoft.Testing.Platform dll + Assembly.GetAssembly(typeof(IProperty))!.Location, + + // Microsoft.Testing.Framework dll + Assembly.GetAssembly(typeof(TestNode))!.Location, + + // Microsoft.Testing.Extensions dll + Assembly.GetAssembly(typeof(TrxReportExtensions))!.Location, + + // Microsoft.Testing.Extensions.TrxReport.Abstractions dll + Assembly.GetAssembly(typeof(TrxExceptionProperty))!.Location, + + // MSTest.TestFramework dll + Assembly.GetAssembly(typeof(TestClassAttribute))!.Location, + }); + + public static ImmutableArray? Net60MetadataReferences { get; set; } + + public async Task CompileAndExecuteAsync(string source, CancellationToken cancellationToken) + => await CompileAndExecuteAsync(new[] { source }, cancellationToken); + + public async Task CompileAndExecuteAsync(string[] sources, CancellationToken cancellationToken) + { + // Cache the resolution in local and try to fire the finalizers + // In CI sometime we have a crash for http connection and the suspect is + // this call below that connects to nuget.org + if (Net60MetadataReferences is null) + { + await Lock.WaitAsync(cancellationToken); + try + { + if (Net60MetadataReferences is null) + { + Net60MetadataReferences = + await ReferenceAssemblies.Net.Net60.ResolveAsync(LanguageNames.CSharp, cancellationToken); + + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + } + } + finally + { + Lock.Release(); + } + } + + MetadataReference[] metadataReferences = + Net60MetadataReferences.Value + .Concat(_additionalReferences.Select(loc => MetadataReference.CreateFromFile(loc))) + .ToArray(); + + var compilation = CSharpCompilation.Create( + "TestAssembly", + sources.Select(source => CSharpSyntaxTree.ParseText(source, cancellationToken: cancellationToken)), + metadataReferences, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + ISourceGenerator generator = _incrementalGeneratorFactory().AsSourceGenerator(); + GeneratorDriver driver = CSharpGeneratorDriver.Create( + generators: new ISourceGenerator[] { generator }); + + driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out Compilation? outputCompilation, + out ImmutableArray diagnostics, cancellationToken); + diagnostics.Should().BeEmpty(); + + using var ms = new MemoryStream(); + EmitResult result = outputCompilation.Emit(ms, cancellationToken: cancellationToken); + + GeneratorDriverRunResult runResult = driver.GetRunResult(); + GeneratorDriverTimingInfo timingInfo = driver.GetTimingInfo(); + + if (result.Success) + { + return new(runResult, timingInfo, result, null); + } + + var code = new StringBuilder(); + + // Append diagnostics that are not tied to any file. + foreach (Diagnostic? globalDiagnostic in result.Diagnostics.Where(d => d.Location.SourceTree == null || string.IsNullOrWhiteSpace(d.Location.SourceTree.FilePath))) + { + code.AppendLine(globalDiagnostic.ToString()); + } + + foreach (SyntaxTree output in outputCompilation.SyntaxTrees) + { + IEnumerable d = output.GetDiagnostics(cancellationToken); + + var diagnosticsByLine = new Dictionary>(); + result.Diagnostics + .Where(d => !string.IsNullOrEmpty(output.FilePath) && d.Location.SourceTree?.FilePath == output.FilePath) + .GroupBy(d => d.Location.GetLineSpan().StartLinePosition) + .ToList() + .ForEach(f => + { + if (diagnosticsByLine.TryGetValue(f.Key.Line, out List? list)) + { + list.AddRange(f); + } + else + { + var l = new List(); + l.AddRange(f); + diagnosticsByLine[f.Key.Line] = l; + } + }); + + if (diagnosticsByLine.Count == 0) + { + continue; + } + + code.Append("file '").Append(output.FilePath).AppendLine("':"); + string[] lines = output.ToString().Split('\n'); + int length = lines.Length; + int pad = length.ToString(CultureInfo.InvariantCulture).Length; + for (int i = 0; i < length; i++) + { + if (diagnosticsByLine.TryGetValue(i, out List? diagnosticsForLine)) + { + code.AppendLine(); + foreach (Diagnostic diagnostic in diagnosticsForLine) + { + code.Append(">>> ").AppendLine(diagnostic.ToString()); + } + } + + // Add line number (starting from 1) + code.Append((i + 1).ToString(CultureInfo.InvariantCulture).PadLeft(pad, '0')); + code.Append(' ').AppendLine(lines[i]); + } + + code.AppendLine(); + } + + return new(runResult, timingInfo, result, code.ToString()); + } +} diff --git a/test/UnitTests/MSTest.SourceGeneration.UnitTests/TestUtilities/TestingFrameworkVerifier.cs b/test/UnitTests/MSTest.SourceGeneration.UnitTests/TestUtilities/TestingFrameworkVerifier.cs new file mode 100644 index 0000000000..c870abcfe3 --- /dev/null +++ b/test/UnitTests/MSTest.SourceGeneration.UnitTests/TestUtilities/TestingFrameworkVerifier.cs @@ -0,0 +1,137 @@ +// 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.Collections.Immutable; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Testing; + +namespace Microsoft.Testing.Framework.SourceGeneration.UnitTests.TestUtilities; + +internal sealed class TestingFrameworkVerifier : IVerifier +{ + public TestingFrameworkVerifier() + : this(ImmutableStack.Empty) + { + } + + internal TestingFrameworkVerifier(ImmutableStack context) + => Context = context ?? throw new ArgumentNullException(nameof(context)); + + public ImmutableStack Context { get; } + + public void Empty(string collectionName, IEnumerable collection) => Assert.IsFalse(collection?.Any() == true, CreateMessage($"expected '{collectionName}' to be empty, contains '{collection?.Count()}' elements")); + + public void Equal(T expected, T actual, string? message = null) + { + if (message is null && Context.IsEmpty) + { + Assert.AreEqual(expected, actual); + } + else + { + Assert.AreEqual(expected, actual, CreateMessage(message)); + } + } + + [DoesNotReturn] + public void Fail(string? message = null) + { + if (message is null && Context.IsEmpty) + { + Assert.Fail(); + } + else + { + Assert.Fail(CreateMessage(message)); + } + + throw new InvalidOperationException("This program location is thought to be unreachable."); + } + + public void False([DoesNotReturnIf(true)] bool assert, string? message = null) + { + if (message is null && Context.IsEmpty) + { + Assert.IsFalse(assert); + } + else + { + Assert.IsFalse(assert, CreateMessage(message)); + } + } + + public void LanguageIsSupported(string language) => Assert.IsFalse(language is not LanguageNames.CSharp and not LanguageNames.VisualBasic, CreateMessage($"Unsupported Language: '{language}'")); + + public void NotEmpty(string collectionName, IEnumerable collection) => Assert.IsTrue(collection?.Any() == true, CreateMessage($"expected '{collectionName}' to be non-empty, contains")); + + public IVerifier PushContext(string context) + { + Assert.AreEqual(typeof(TestingFrameworkVerifier), GetType()); + return new TestingFrameworkVerifier(Context.Push(context)); + } + + public void SequenceEqual(IEnumerable expected, IEnumerable actual, IEqualityComparer? equalityComparer = null, string? message = null) + { + var comparer = new SequenceEqualEnumerableEqualityComparer(equalityComparer); + bool areEqual = comparer.Equals(expected, actual); + if (!areEqual) + { + Assert.Fail(CreateMessage(message)); + } + } + + public void True([DoesNotReturnIf(false)] bool assert, string? message = null) + { + if (message is null && Context.IsEmpty) + { + Assert.IsTrue(assert); + } + else + { + Assert.IsTrue(assert, CreateMessage(message)); + } + } + + private string CreateMessage(string? message) + { + foreach (string frame in Context) + { + message = "Context: " + frame + Environment.NewLine + message; + } + + return message ?? string.Empty; + } + + private sealed class SequenceEqualEnumerableEqualityComparer : IEqualityComparer?> + { + private readonly IEqualityComparer _itemEqualityComparer; + + public SequenceEqualEnumerableEqualityComparer(IEqualityComparer? itemEqualityComparer) + { + _itemEqualityComparer = itemEqualityComparer ?? EqualityComparer.Default; + } + + public bool Equals(IEnumerable? x, IEnumerable? y) + => ReferenceEquals(x, y) + || (x is not null && y is not null && x.SequenceEqual(y, _itemEqualityComparer)); + + public int GetHashCode(IEnumerable? obj) + { + if (obj is null) + { + return 0; + } + + // From System.Tuple + // + // The suppression is required due to an invalid contract in IEqualityComparer + // https://github.com/dotnet/runtime/issues/30998 + return obj + .Select(item => _itemEqualityComparer.GetHashCode(item!)) + .Aggregate( + 0, + (aggHash, nextHash) => ((aggHash << 5) + aggHash) ^ nextHash); + } + } +} From 292b35213fc164ad3c56dac87547d434c9b2b0af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Tue, 11 Feb 2025 15:21:15 +0100 Subject: [PATCH 2/3] Fixes --- .../Helpers/SystemPolyfills.cs | 12 ++++++------ .../Helpers/MinimalTestRunner.cs | 2 -- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/Analyzers/MSTest.SourceGeneration/Helpers/SystemPolyfills.cs b/src/Analyzers/MSTest.SourceGeneration/Helpers/SystemPolyfills.cs index 6b6fe8dda3..2faf43d57a 100644 --- a/src/Analyzers/MSTest.SourceGeneration/Helpers/SystemPolyfills.cs +++ b/src/Analyzers/MSTest.SourceGeneration/Helpers/SystemPolyfills.cs @@ -2,14 +2,17 @@ // Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. #if !NETCOREAPP +#pragma warning disable SA1403 // File may only contain a single namespace +#pragma warning disable SA1623 // Property summary documentation should match accessors +#pragma warning disable SA1642 // Constructor summary documentation should begin with standard text +#pragma warning disable SA1502 // Element should not be on a single line using System.ComponentModel; namespace System.Runtime.CompilerServices { - [EditorBrowsable(EditorBrowsableState.Never)] - internal class IsExternalInit { } + internal sealed class IsExternalInit { } } // This was copied from https://github.com/dotnet/coreclr/blob/60f1e6265bd1039f023a82e0643b524d6aaf7845/src/System.Private.CoreLib/shared/System/Diagnostics/CodeAnalysis/NullableAttributes.cs @@ -155,10 +158,7 @@ internal sealed class CompilerFeatureRequiredAttribute : Attribute /// Creates a new instance of the type. /// /// The name of the feature to indicate. - public CompilerFeatureRequiredAttribute(string featureName) - { - FeatureName = featureName; - } + public CompilerFeatureRequiredAttribute(string featureName) => FeatureName = featureName; /// /// The name of the compiler feature. diff --git a/test/UnitTests/MSTest.SourceGeneration.UnitTests/Helpers/MinimalTestRunner.cs b/test/UnitTests/MSTest.SourceGeneration.UnitTests/Helpers/MinimalTestRunner.cs index 28c3092327..e00e391545 100644 --- a/test/UnitTests/MSTest.SourceGeneration.UnitTests/Helpers/MinimalTestRunner.cs +++ b/test/UnitTests/MSTest.SourceGeneration.UnitTests/Helpers/MinimalTestRunner.cs @@ -1,8 +1,6 @@ // 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; - /// /// Discovers and runs tests using the MSTest attributes, so we can run tests even when we completely break or delete the real MSTest engine. /// From 3440209ef54f744f90d2228429097bc033422661 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Tue, 11 Feb 2025 15:29:17 +0100 Subject: [PATCH 3/3] Address review feedback --- src/Adapter/MSTest.Engine/MSTest.Engine.csproj | 13 +++++++++++++ .../Helpers/SystemPolyfills.cs | 2 +- .../MSTest.SourceGeneration.csproj | 13 +++++++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/Adapter/MSTest.Engine/MSTest.Engine.csproj b/src/Adapter/MSTest.Engine/MSTest.Engine.csproj index 6da901454d..9fee56b5c7 100644 --- a/src/Adapter/MSTest.Engine/MSTest.Engine.csproj +++ b/src/Adapter/MSTest.Engine/MSTest.Engine.csproj @@ -16,6 +16,19 @@ $(NoWarn);CS1591 + + true + false + + true + true + + false diff --git a/src/Analyzers/MSTest.SourceGeneration/Helpers/SystemPolyfills.cs b/src/Analyzers/MSTest.SourceGeneration/Helpers/SystemPolyfills.cs index 2faf43d57a..7a3645233e 100644 --- a/src/Analyzers/MSTest.SourceGeneration/Helpers/SystemPolyfills.cs +++ b/src/Analyzers/MSTest.SourceGeneration/Helpers/SystemPolyfills.cs @@ -12,7 +12,7 @@ namespace System.Runtime.CompilerServices { [EditorBrowsable(EditorBrowsableState.Never)] - internal sealed class IsExternalInit { } + internal static class IsExternalInit { } } // This was copied from https://github.com/dotnet/coreclr/blob/60f1e6265bd1039f023a82e0643b524d6aaf7845/src/System.Private.CoreLib/shared/System/Diagnostics/CodeAnalysis/NullableAttributes.cs diff --git a/src/Analyzers/MSTest.SourceGeneration/MSTest.SourceGeneration.csproj b/src/Analyzers/MSTest.SourceGeneration/MSTest.SourceGeneration.csproj index 89f5985c1d..e6206b247a 100644 --- a/src/Analyzers/MSTest.SourceGeneration/MSTest.SourceGeneration.csproj +++ b/src/Analyzers/MSTest.SourceGeneration/MSTest.SourceGeneration.csproj @@ -21,6 +21,19 @@ true + + true + false + + true + true + +