diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 82ce0c88e5..f96da32578 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -107,6 +107,7 @@ When making change to resource files, you MUST: - Every API marked with `[Experimental]` MUST include this sentence in its XML documentation ``: `This API is experimental. It may change, break, or be removed at any time without notice.` Documentation tooling does not reliably surface the attribute itself. - Add the sentence in a `` when `` already contains other text; otherwise, add a new `` block. - Apply this rule to experimental members as well as types. +- When designing a capability API or protocol field, consider clients that predate the capability. Model "unsupported or not declared" separately from an explicit value (for example, with a nullable value or presence-aware representation) unless absence is intentionally equivalent to the default, and document the compatibility behavior. ## Testing Guidelines diff --git a/docs/mstest-runner-protocol/001-protocol-intro.md b/docs/mstest-runner-protocol/001-protocol-intro.md index 6d38c95d74..7a4c6e15df 100644 --- a/docs/mstest-runner-protocol/001-protocol-intro.md +++ b/docs/mstest-runner-protocol/001-protocol-intro.md @@ -257,10 +257,11 @@ interface InitializeParams { // If true, the client is stateful: it persists an addressable set of test nodes for the // whole session and keeps each node in its last-known state until it is explicitly updated - // (for example, an IDE test explorer). If false or missing, the client is stateless: it - // consumes test updates as a stream and does not retain node state after the run - // (for example, `dotnet test`). This is independent of connection lifetime and - // experimental_multiRequestSupport. Defaults to false. + // (for example, an IDE test explorer). If false, the client explicitly declares that it is + // stateless and consumes test updates as a stream without retaining node state after the run + // (for example, `dotnet test`). If missing, the client does not support or did not declare + // the capability, allowing consumers to apply compatibility behavior for known legacy clients. + // This is independent of connection lifetime and experimental_multiRequestSupport. isStateful?: boolean, }, } diff --git a/docs/mstest-runner-protocol/server-mode-1.0.schema.json b/docs/mstest-runner-protocol/server-mode-1.0.schema.json index 221863bcf7..2fd3533be5 100644 --- a/docs/mstest-runner-protocol/server-mode-1.0.schema.json +++ b/docs/mstest-runner-protocol/server-mode-1.0.schema.json @@ -129,8 +129,7 @@ "type": "boolean" }, "isStateful": { - "type": "boolean", - "default": false + "type": "boolean" } }, "additionalProperties": true diff --git a/src/Adapter/MSTest.TestAdapter/TestingPlatformAdapter/MSTestRunSettings.cs b/src/Adapter/MSTest.TestAdapter/TestingPlatformAdapter/MSTestRunSettings.cs index 84eac671e9..0b422252bf 100644 --- a/src/Adapter/MSTest.TestAdapter/TestingPlatformAdapter/MSTestRunSettings.cs +++ b/src/Adapter/MSTest.TestAdapter/TestingPlatformAdapter/MSTestRunSettings.cs @@ -84,7 +84,8 @@ internal static string ReadRunSettings(string[]? fileNames, IFileSystem fileSyst private static XDocument Patch(string? runSettingsXml, IConfiguration configuration, IClientInfo client, ICommandLineOptions commandLineOptions) { // Keep recognizing older Visual Studio clients that predate the statefulness capability. - bool isDesignMode = client.Capabilities.IsStateful || client.Id == WellKnownClients.VisualStudio; + bool isDesignMode = client.Capabilities.GetIsStateful() + ?? client.Id == WellKnownClients.VisualStudio; XDocument runSettingsDocument = PatchSettingsWithDefaults(runSettingsXml, isDesignMode, configuration); PatchTestRunParameters(runSettingsDocument, commandLineOptions); return runSettingsDocument; diff --git a/src/Analyzers/MSTest.Analyzers/ReviewAlwaysTrueAssertConditionAnalyzer.cs b/src/Analyzers/MSTest.Analyzers/ReviewAlwaysTrueAssertConditionAnalyzer.cs index 3693e2a105..1d431b6fa3 100644 --- a/src/Analyzers/MSTest.Analyzers/ReviewAlwaysTrueAssertConditionAnalyzer.cs +++ b/src/Analyzers/MSTest.Analyzers/ReviewAlwaysTrueAssertConditionAnalyzer.cs @@ -69,6 +69,7 @@ private static bool IsAlwaysTrue(IInvocationOperation operation) "IsTrue" => AssertConditionAnalyzerHelper.GetConditionArgument(operation) is { ConstantValue: { HasValue: true, Value: true } }, "IsFalse" => AssertConditionAnalyzerHelper.GetConditionArgument(operation) is { ConstantValue: { HasValue: true, Value: false } }, "AreEqual" => !AssertConditionAnalyzerHelper.HasNonDefaultEqualityComparerArgument(operation) + && !IsEnumUnderlyingValueContractAssertion(operation) && (AssertConditionAnalyzerHelper.GetEqualityStatus(operation, AssertConditionAnalyzerHelper.ExpectedParameterName) == AssertConditionAnalyzerHelper.EqualityStatus.Equal || AssertConditionAnalyzerHelper.HasIdenticalExpectedAndActualWithBuiltInEquality(operation, AssertConditionAnalyzerHelper.ExpectedParameterName)), "AreNotEqual" => !AssertConditionAnalyzerHelper.HasNonDefaultEqualityComparerArgument(operation) @@ -78,4 +79,64 @@ private static bool IsAlwaysTrue(IInvocationOperation operation) "IsNotNull" => AssertConditionAnalyzerHelper.GetValueArgument(operation) is { } valueArgumentOperation && AssertConditionAnalyzerHelper.IsNotNullableType(valueArgumentOperation), _ => false, }; + + private static bool IsEnumUnderlyingValueContractAssertion(IInvocationOperation operation) + { + IOperation? expectedArgument = operation.Arguments.FirstOrDefault(argument => argument.Parameter?.Name == AssertConditionAnalyzerHelper.ExpectedParameterName)?.Value; + IOperation? actualArgument = operation.Arguments.FirstOrDefault(argument => argument.Parameter?.Name == AssertConditionAnalyzerHelper.ActualParameterName)?.Value; + + return expectedArgument is not null + && actualArgument is not null + && ((IsNumericLiteral(expectedArgument) && IsEnumMemberConvertedToUnderlyingType(actualArgument)) + || (IsEnumMemberConvertedToUnderlyingType(expectedArgument) && IsNumericLiteral(actualArgument))); + } + + private static bool IsNumericLiteral(IOperation operation) + => WalkDownImplicitConversionsAndParentheses(operation) switch + { + ILiteralOperation { Type.SpecialType: var specialType } => IsIntegralNumericType(specialType), + IUnaryOperation { OperatorKind: UnaryOperatorKind.Plus or UnaryOperatorKind.Minus, Operand: { } operand } => IsNumericLiteral(operand), + _ => false, + }; + + private static bool IsIntegralNumericType(SpecialType specialType) + => specialType is SpecialType.System_SByte + or SpecialType.System_Byte + or SpecialType.System_Int16 + or SpecialType.System_UInt16 + or SpecialType.System_Int32 + or SpecialType.System_UInt32 + or SpecialType.System_Int64 + or SpecialType.System_UInt64; + + private static bool IsEnumMemberConvertedToUnderlyingType(IOperation operation) + { + operation = WalkDownImplicitConversionsAndParentheses(operation); + if (operation is not IConversionOperation + { + IsImplicit: false, + Type: { } convertedType, + Operand: { } operand, + }) + { + return false; + } + + operand = WalkDownImplicitConversionsAndParentheses(operand); + return GetEnumUnderlyingType(operand) is { } underlyingType + && SymbolEqualityComparer.Default.Equals(convertedType, underlyingType); + } + + private static ITypeSymbol? GetEnumUnderlyingType(IOperation operation) + => operation is IFieldReferenceOperation { Field: { HasConstantValue: true, ContainingType: { TypeKind: TypeKind.Enum } enumType } } + ? enumType.EnumUnderlyingType + : null; + + private static IOperation WalkDownImplicitConversionsAndParentheses(IOperation operation) + => operation switch + { + IConversionOperation { IsImplicit: true } conversion => WalkDownImplicitConversionsAndParentheses(conversion.Operand), + IParenthesizedOperation parenthesizedOperation => WalkDownImplicitConversionsAndParentheses(parenthesizedOperation.Operand), + _ => operation, + }; } diff --git a/src/Analyzers/MSTest.SourceGeneration/Generators/MetadataRegistryEmitter.cs b/src/Analyzers/MSTest.SourceGeneration/Generators/MetadataRegistryEmitter.cs index 4b4e48ee76..87056a562b 100644 --- a/src/Analyzers/MSTest.SourceGeneration/Generators/MetadataRegistryEmitter.cs +++ b/src/Analyzers/MSTest.SourceGeneration/Generators/MetadataRegistryEmitter.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. using Microsoft.VisualStudio.TestPlatform.MSTestAdapter.PlatformServices.SourceGeneration.Helpers; @@ -58,6 +58,7 @@ public static string EmitSupportTypes() using (sb.Block("internal sealed class TestMethodReflectionInfo")) { sb.AppendLine("public string Name { get; set; } = string.Empty;"); + sb.AppendLine("public Type DeclaringType { get; set; } = null!;"); sb.AppendLine("public bool IsTestMethod { get; set; }"); sb.AppendLine("public bool IsStatic { get; set; }"); sb.AppendLine("public bool IsAsync { get; set; }"); @@ -243,6 +244,7 @@ private static void EmitMethods(IndentedStringBuilder sb, string fqn, TestClassM using (sb.Block(null)) { sb.AppendLine($"Name = \"{Escape(method.Name)}\","); + sb.AppendLine($"DeclaringType = typeof({method.DeclaringTypeFullyQualifiedName}),"); sb.AppendLine($"IsTestMethod = {Bool(method.IsTestMethod)},"); sb.AppendLine($"IsStatic = {Bool(method.IsStatic)},"); sb.AppendLine($"IsAsync = {Bool(method.IsAsync)},"); diff --git a/src/Analyzers/MSTest.SourceGeneration/Generators/RuntimeRegistrationEmitter.cs b/src/Analyzers/MSTest.SourceGeneration/Generators/RuntimeRegistrationEmitter.cs index 9864aa7476..ed41749066 100644 --- a/src/Analyzers/MSTest.SourceGeneration/Generators/RuntimeRegistrationEmitter.cs +++ b/src/Analyzers/MSTest.SourceGeneration/Generators/RuntimeRegistrationEmitter.cs @@ -192,7 +192,7 @@ private static void EmitInitializeBody(IndentedStringBuilder sb, IReadOnlyList(StringComparer.Ordinal); - var propertiesByName = new Dictionary(StringComparer.Ordinal); + var seenPropertyNames = new HashSet(StringComparer.Ordinal); + var methodNamesInDerivedTypes = new HashSet(StringComparer.Ordinal); + var nonMethodNamesInDerivedTypes = new HashSet(StringComparer.Ordinal); + var methodsInDerivedTypes = new List(); ImmutableArray.Builder methods = ImmutableArray.CreateBuilder(); ImmutableArray.Builder properties = ImmutableArray.CreateBuilder(); ImmutableArray.Builder ctors = ImmutableArray.CreateBuilder(); @@ -66,6 +70,7 @@ public static TestClassModel Build(INamedTypeSymbol typeSymbol, List currentMembers = current.GetMembers(); // Capture each closed, referenceable base type so the runtime registration can root // its members (e.g. base-declared [ClassInitialize]/[TestContext]) via [DynamicDependency] @@ -76,14 +81,35 @@ public static TestClassModel Build(INamedTypeSymbol typeSymbol, List inheritedAttributes = AttributeMaterializationHelper.CollectInheritedAttributes(method); bool isTestMethod = TestMemberValidationHelper.IsTestMethodAttributePresent(inheritedAttributes); - if (!TestMemberValidationHelper.IsAccessibleFromConsumer(method)) + bool hiddenByNonMethod = nonMethodNamesInDerivedTypes.Contains(method.Name); + bool hiddenByMethodGroup = methodNamesInDerivedTypes.Contains(method.Name); + bool isAccessible = TestMemberValidationHelper.IsAccessibleFromConsumer(method, consumingAssembly); + if ((hiddenByNonMethod || hiddenByMethodGroup) + && isAccessible + && TestMemberValidationHelper.TryReportUnsupportedMethod(method, leafFqn, diagnostics)) + { + hasUnsupportedTestMethod |= isTestMethod; + break; + } + + if (hiddenByNonMethod || hiddenByMethodGroup) + { + hasUnsupportedTestMethod |= isTestMethod + && (hiddenByNonMethod + || !methodsInDerivedTypes.Any(derivedMethod => + ReplacesInheritedRuntimeTest(derivedMethod) + && TestMemberValidationHelper.HaveSameRuntimeDiscoverySignature(derivedMethod, method))); + break; + } + + if (!isAccessible) { hasUnsupportedTestMethod |= isTestMethod; break; @@ -98,25 +124,23 @@ public static TestClassModel Build(INamedTypeSymbol typeSymbol, List method is not null && TestMemberValidationHelper.IsTestMethodAttributePresent(AttributeMaterializationHelper.CollectInheritedAttributes(method)); + private static bool ReplacesInheritedRuntimeTest(IMethodSymbol method) + => method.OverriddenMethod is not null + || (method is { DeclaredAccessibility: Accessibility.Public, IsStatic: false } + && HasTestMethodAttribute(method)); + private static TestMethodModel BuildMethod( IMethodSymbol method, IAssemblySymbol consumingAssembly, @@ -237,6 +284,7 @@ private static TestMethodModel BuildMethod( return new TestMethodModel( Name: method.Name, + DeclaringTypeFullyQualifiedName: method.ContainingType.ToDisplayString(SymbolDisplayFormats.FullyQualified), IsStatic: method.IsStatic, IsAsync: method.IsAsync, ReturnsTask: returnsTask, @@ -282,15 +330,11 @@ private static TestPropertyModel BuildProperty(IPropertySymbol property, IAssemb FullyQualifiedType: property.Type.ToDisplayString(SymbolDisplayFormats.FullyQualified), IsStatic: property.IsStatic, - // The generated registry lives in the consuming assembly, so a getter is reachable - // when it is public, internal, or protected-internal. private / protected getters - // cannot be read from the generated (non-derived) call site. - HasGettableValue: property.GetMethod is - { - DeclaredAccessibility: Accessibility.Public - or Accessibility.Internal - or Accessibility.ProtectedOrInternal, - }, + HasGettableValue: property.GetMethod is { } getter + && SymbolReferenceabilityHelper.IsMemberAccessibleFrom( + getter.DeclaredAccessibility, + getter.ContainingType, + consumingAssembly), // An init-only setter has public DeclaredAccessibility but cannot be assigned outside an // object initializer, so emitting `instance.Prop = value` would not compile (CS8852); // treat it as non-settable so the adapter falls back to reflection (PropertyInfo.SetValue). diff --git a/src/Analyzers/MSTest.SourceGeneration/Generators/TestMemberValidationHelper.cs b/src/Analyzers/MSTest.SourceGeneration/Generators/TestMemberValidationHelper.cs index 4328995ba6..cb1dc16be7 100644 --- a/src/Analyzers/MSTest.SourceGeneration/Generators/TestMemberValidationHelper.cs +++ b/src/Analyzers/MSTest.SourceGeneration/Generators/TestMemberValidationHelper.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. using System.Collections.Immutable; @@ -19,13 +19,13 @@ internal static class TestMemberValidationHelper // Restricted to accessibilities the emitted helper class (a separate static type // declared in MSTest.SourceGenerated, not a derived type) can legally call. // 'protected' and 'private protected' members require the caller to be a derived - // type, so they are excluded; 'protected internal' is included because the internal - // half is satisfied (the generated helper lives in the same assembly). - internal static bool IsAccessibleFromConsumer(ISymbol symbol) - => symbol.DeclaredAccessibility is - Accessibility.Public - or Accessibility.Internal - or Accessibility.ProtectedOrInternal; + // type, so they are excluded. Internal access is available only for members declared + // in the consuming assembly. + internal static bool IsAccessibleFromConsumer(ISymbol symbol, IAssemblySymbol consumingAssembly) + => SymbolReferenceabilityHelper.IsMemberAccessibleFrom( + symbol.DeclaredAccessibility, + symbol.ContainingAssembly, + consumingAssembly); internal static bool IsTestMethodAttributePresent(ImmutableArray attributes) { @@ -85,44 +85,79 @@ internal static bool IsSupportedTestClassConstructor(IMethodSymbol constructor) && parameters[0].Type.ToDisplayString(SymbolDisplayFormats.FullyQualified) == "global::" + MSTestAttributeNames.UnitTestingNamespace + ".TestContext"); } - internal static string BuildMethodSignatureKey(IMethodSymbol method) + // Mirrors TypeEnumerator's MethodInfo.ToString()-based discovery identity. In particular, + // generic parameter names remain significant because reflection formats them into that string. + internal static bool HaveSameRuntimeDiscoverySignature(IMethodSymbol left, IMethodSymbol right) { - var sb = new StringBuilder(); - sb.Append(method.IsStatic ? "S:" : "I:"); - sb.Append(method.Name); - if (method.Arity > 0) + if (!string.Equals(left.Name, right.Name, StringComparison.Ordinal) + || left.Arity != right.Arity + || left.Parameters.Length != right.Parameters.Length + || (left.IsStatic && !right.IsStatic) + || !AreSignatureTypesEquivalent(left.ReturnType, right.ReturnType)) { - sb.Append('`'); - sb.Append(method.Arity); + return false; } - sb.Append('('); - bool first = true; - foreach (IParameterSymbol p in method.Parameters) + for (int index = 0; index < left.Parameters.Length; index++) { - if (!first) + IParameterSymbol leftParameter = left.Parameters[index]; + IParameterSymbol rightParameter = right.Parameters[index]; + if ((leftParameter.RefKind == RefKind.None) != (rightParameter.RefKind == RefKind.None) + || !AreSignatureTypesEquivalent(leftParameter.Type, rightParameter.Type)) { - sb.Append(','); + return false; } + } + + return true; + } + + private static bool AreSignatureTypesEquivalent(ITypeSymbol left, ITypeSymbol right) + { + if (left is IDynamicTypeSymbol) + { + return right is IDynamicTypeSymbol || right.SpecialType == SpecialType.System_Object; + } + + if (right is IDynamicTypeSymbol) + { + return left.SpecialType == SpecialType.System_Object; + } + + if (left is ITypeParameterSymbol leftTypeParameter && right is ITypeParameterSymbol rightTypeParameter) + { + return leftTypeParameter.TypeParameterKind == rightTypeParameter.TypeParameterKind + && string.Equals(leftTypeParameter.Name, rightTypeParameter.Name, StringComparison.Ordinal); + } + + if (left is IArrayTypeSymbol leftArray && right is IArrayTypeSymbol rightArray) + { + return leftArray.Rank == rightArray.Rank + && AreSignatureTypesEquivalent(leftArray.ElementType, rightArray.ElementType); + } - first = false; - switch (p.RefKind) + if (left is INamedTypeSymbol leftNamed && right is INamedTypeSymbol rightNamed) + { + if (leftNamed.TypeArguments.Length != rightNamed.TypeArguments.Length + || !SymbolEqualityComparer.Default.Equals(leftNamed.OriginalDefinition, rightNamed.OriginalDefinition) + || (leftNamed.ContainingType is null) != (rightNamed.ContainingType is null) + || (leftNamed.ContainingType is not null + && !AreSignatureTypesEquivalent(leftNamed.ContainingType, rightNamed.ContainingType!))) { - case RefKind.Ref: - sb.Append("ref "); - break; - case RefKind.Out: - sb.Append("out "); - break; - case RefKind.In: - sb.Append("in "); - break; + return false; + } + + for (int index = 0; index < leftNamed.TypeArguments.Length; index++) + { + if (!AreSignatureTypesEquivalent(leftNamed.TypeArguments[index], rightNamed.TypeArguments[index])) + { + return false; + } } - sb.Append(p.Type.ToDisplayString(SymbolDisplayFormats.FullyQualified)); + return true; } - sb.Append(')'); - return sb.ToString(); + return SymbolEqualityComparer.Default.Equals(left, right); } } diff --git a/src/Analyzers/MSTest.SourceGeneration/Models/TestClassModel.cs b/src/Analyzers/MSTest.SourceGeneration/Models/TestClassModel.cs index ec883f9fb1..1005751466 100644 --- a/src/Analyzers/MSTest.SourceGeneration/Models/TestClassModel.cs +++ b/src/Analyzers/MSTest.SourceGeneration/Models/TestClassModel.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. using MSTest.Analyzers.Shared; @@ -65,6 +65,7 @@ internal sealed record DynamicDataSourceModel( internal sealed record TestMethodModel( string Name, + string DeclaringTypeFullyQualifiedName, bool IsStatic, bool IsAsync, bool ReturnsTask, diff --git a/src/Platform/Microsoft.Testing.Extensions.VSTestBridge/ObjectModel/RunSettingsPatcher.cs b/src/Platform/Microsoft.Testing.Extensions.VSTestBridge/ObjectModel/RunSettingsPatcher.cs index af364a5874..aa91d1fff3 100644 --- a/src/Platform/Microsoft.Testing.Extensions.VSTestBridge/ObjectModel/RunSettingsPatcher.cs +++ b/src/Platform/Microsoft.Testing.Extensions.VSTestBridge/ObjectModel/RunSettingsPatcher.cs @@ -18,7 +18,8 @@ internal static class RunSettingsPatcher public static XDocument Patch(string? runSettingsXml, IConfiguration configuration, IClientInfo client, ICommandLineOptions commandLineOptions) { // Keep recognizing older Visual Studio clients that predate the statefulness capability. - bool isDesignMode = client.Capabilities.IsStateful || client.Id == WellKnownClients.VisualStudio; + bool isDesignMode = client.Capabilities.GetIsStateful() + ?? client.Id == WellKnownClients.VisualStudio; XDocument runSettingsDocument = PatchSettingsWithDefaults(runSettingsXml, isDesignMode, configuration); PatchTestRunParameters(runSettingsDocument, commandLineOptions); diff --git a/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Client/MtpServerClientOptions.cs b/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Client/MtpServerClientOptions.cs index f64a1e7296..8aef698d35 100644 --- a/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Client/MtpServerClientOptions.cs +++ b/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Client/MtpServerClientOptions.cs @@ -38,13 +38,14 @@ internal sealed class MtpServerClientOptions /// /// Gets or sets a value indicating whether the client persists an addressable set of test nodes for the /// whole session and keeps each node in its last-known state until explicitly updated - /// (capabilities.testing.isStateful). Defaults to . + /// (capabilities.testing.isStateful). omits the capability from the initialize + /// handshake. Defaults to . /// /// /// This capability describes how the client consumes test-node updates. It is independent of connection /// lifetime and the server's ServerCapabilities.MultiRequestSupport capability. /// - public bool IsStateful { get; set; } + public bool? IsStateful { get; set; } /// /// Gets or sets how long to wait for the launched test app to connect back to the client's loopback diff --git a/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Client/SerializerUtilities.ClientSerializers.cs b/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Client/SerializerUtilities.ClientSerializers.cs index e04c5d0b9e..aa610912e8 100644 --- a/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Client/SerializerUtilities.ClientSerializers.cs +++ b/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Client/SerializerUtilities.ClientSerializers.cs @@ -61,13 +61,22 @@ private static void RegisterClientSerializersCore() [JsonRpcStrings.Version] = info.Version, }); - Serializers[typeof(ClientCapabilities)] = new ObjectSerializer(capabilities => new Dictionary + Serializers[typeof(ClientCapabilities)] = new ObjectSerializer(capabilities => { - [JsonRpcStrings.Testing] = new Dictionary + Dictionary testingCapabilities = new() { [JsonRpcStrings.DebuggerProvider] = capabilities.DebuggerProvider, - [JsonRpcStrings.IsStateful] = capabilities.IsStateful, - }, + }; + + if (capabilities.IsStateful is { } isStateful) + { + testingCapabilities[JsonRpcStrings.IsStateful] = isStateful; + } + + return new Dictionary + { + [JsonRpcStrings.Testing] = testingCapabilities, + }; }); Serializers[typeof(InitializeRequestArgs)] = new ObjectSerializer(args => diff --git a/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Microsoft.Testing.Platform.ServerMode.Client.Sources.csproj b/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Microsoft.Testing.Platform.ServerMode.Client.Sources.csproj index bfaaaaf955..f2647c83ce 100644 --- a/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Microsoft.Testing.Platform.ServerMode.Client.Sources.csproj +++ b/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Microsoft.Testing.Platform.ServerMode.Client.Sources.csproj @@ -118,6 +118,7 @@ + diff --git a/src/Platform/Microsoft.Testing.Platform/Hosts/ConsoleTestHost.cs b/src/Platform/Microsoft.Testing.Platform/Hosts/ConsoleTestHost.cs index 9615d71384..f13104f585 100644 --- a/src/Platform/Microsoft.Testing.Platform/Hosts/ConsoleTestHost.cs +++ b/src/Platform/Microsoft.Testing.Platform/Hosts/ConsoleTestHost.cs @@ -24,7 +24,7 @@ internal sealed class ConsoleTestHost( : CommonHost(serviceProvider) { private static readonly ClientInfo ClientInfoHost = new("testingplatform-console", PlatformVersion.Version); - private static readonly IClientInfo ClientInfoService = new ClientInfoService("testingplatform-console", PlatformVersion.Version, new ClientCapabilitiesService(IsStateful: false)); + private static readonly IClientInfo ClientInfoService = new ClientInfoService("testingplatform-console", PlatformVersion.Version, new ClientCapabilitiesService(DeclaredIsStateful: false)); private readonly ILogger _logger = serviceProvider.GetLoggerFactory().CreateLogger(); private readonly IClock _clock = serviceProvider.GetClock(); diff --git a/src/Platform/Microsoft.Testing.Platform/InternalAPI/InternalAPI.Unshipped.txt b/src/Platform/Microsoft.Testing.Platform/InternalAPI/InternalAPI.Unshipped.txt index fd505eb330..1711280ab4 100644 --- a/src/Platform/Microsoft.Testing.Platform/InternalAPI/InternalAPI.Unshipped.txt +++ b/src/Platform/Microsoft.Testing.Platform/InternalAPI/InternalAPI.Unshipped.txt @@ -2,6 +2,15 @@ [TPEXP]Microsoft.Testing.Platform.CommandLine.CommandLineParseResult.CommandLineParseResult(string? toolName, System.Collections.Generic.IReadOnlyList! options, System.Collections.Generic.IReadOnlyList! errors, System.Collections.Generic.IReadOnlyList! arguments, System.Collections.Generic.IReadOnlyList! expandedArguments) -> void [TPEXP]Microsoft.Testing.Platform.CommandLine.CommandLineParseResult.ExpandedArguments.get -> System.Collections.Generic.IReadOnlyList! Microsoft.Testing.Platform.Builder.TestApplicationBuilder.TestApplicationBuilder(Microsoft.Testing.Platform.Logging.ApplicationLoggingState! loggingState, System.DateTimeOffset createBuilderStart, Microsoft.Testing.Platform.Builder.TestApplicationOptions! testApplicationOptions, Microsoft.Testing.Platform.Helpers.IUnhandledExceptionsHandler! unhandledExceptionsHandler, string![]! args, string![]! expandedArgs) -> void +Microsoft.Testing.Platform.ServerMode.ClientCapabilities.ClientCapabilities(bool DebuggerProvider, bool? IsStateful) -> void +Microsoft.Testing.Platform.ServerMode.ClientCapabilities.Deconstruct(out bool DebuggerProvider, out bool? IsStateful) -> void +Microsoft.Testing.Platform.ServerMode.ClientCapabilities.IsStateful.get -> bool? +Microsoft.Testing.Platform.ServerMode.ClientCapabilities.IsStateful.init -> void +static Microsoft.Testing.Platform.ServerMode.SerializerUtilities.FormatException(string? explanation, System.Exception? exception) -> (string? Message, string? StackTrace) +Microsoft.Testing.Platform.Services.ClientCapabilitiesService.ClientCapabilitiesService(bool? DeclaredIsStateful) -> void +Microsoft.Testing.Platform.Services.ClientCapabilitiesService.Deconstruct(out bool? DeclaredIsStateful) -> void +Microsoft.Testing.Platform.Services.ClientCapabilitiesService.DeclaredIsStateful.get -> bool? +Microsoft.Testing.Platform.Services.ClientCapabilitiesService.DeclaredIsStateful.init -> void Microsoft.Testing.Platform.Services.CurrentTestApplicationModuleInfo.CurrentTestApplicationModuleInfo(Microsoft.Testing.Platform.Helpers.IEnvironment! environment, Microsoft.Testing.Platform.Helpers.IProcessHandler! process, string![]? commandLineArguments, string![]? expandedCommandLineArguments) -> void Microsoft.Testing.Platform.Services.ExecutableInfo.ExecutableInfo(string! filePath, System.Collections.Generic.IEnumerable! arguments, System.Collections.Generic.IEnumerable! expandedArguments, string! workspace) -> void Microsoft.Testing.Platform.Services.ExecutableInfo.ExpandedArguments.get -> System.Collections.Generic.IEnumerable! @@ -18,16 +27,7 @@ Microsoft.Testing.Platform.Hosts.TestHostControllerCancellationServer.Start() -> Microsoft.Testing.Platform.Hosts.TestHostControllerCancellationServer.TestHostControllerCancellationServer(System.Collections.Generic.IReadOnlyList? authorizedSecurityIdentities, Microsoft.Testing.Platform.Helpers.IEnvironment! environment, Microsoft.Testing.Platform.Logging.ILoggerFactory! loggerFactory, Microsoft.Testing.Platform.Helpers.ITask! task) -> void Microsoft.Testing.Platform.Hosts.TestHostControllerCancellationServer.WaitForRequestAsync() -> System.Threading.Tasks.Task! Microsoft.Testing.Platform.Hosts.TestHostControlledHost.SetCancellationListener(Microsoft.Testing.Platform.Hosts.TestHostControllerCancellationListener? testHostControllerCancellationListener) -> void -<<<<<<< HEAD -<<<<<<< HEAD -<<<<<<< HEAD -======= -======= -static Microsoft.Testing.Platform.Hosts.TestHostControlledHost.RegisterCompletionCancellationTransition(System.Threading.CancellationToken applicationCancellationToken, System.Func! shouldReportCompletionAfterCancellation, System.Threading.CancellationTokenSource! completionCancellationTokenSource) -> System.Threading.CancellationTokenRegistration ->>>>>>> Preserve late cooperative completion reporting -======= static Microsoft.Testing.Platform.Hosts.TestHostControlledHost.RegisterCompletionCancellationTransition(System.Threading.CancellationToken applicationCancellationToken, System.Func! shouldReportCompletionAfterCancellation, System.Threading.CancellationTokenSource! completionCancellationTokenSource, System.TimeSpan cooperativeCompletionTimeout) -> System.Threading.CancellationTokenRegistration ->>>>>>> Verify delayed completion cancellation Microsoft.Testing.Platform.Services.CTRLPlusCCancellationTokenSource.WasCancellationRequestedByConsole.get -> bool Microsoft.Testing.Platform.Services.CTRLPlusCCancellationTokenSource.RegisterForceExitAction(System.Action! forceExitAction) -> System.IDisposable! const Microsoft.Testing.Platform.Helpers.EnvironmentVariableConstants.TESTINGPLATFORM_TESTHOSTCONTROLLER_CONTROLPIPENAME = "TESTINGPLATFORM_TESTHOSTCONTROLLER_CONTROLPIPENAME" -> string! @@ -48,4 +48,3 @@ static Microsoft.Testing.Platform.Messages.ShutdownTimeouts.GetCanceledConsumerC *REMOVED*Microsoft.Testing.Platform.Services.ClientCapabilitiesService.ClientCapabilitiesService(bool IsStateful) -> void *REMOVED*Microsoft.Testing.Platform.Services.ClientCapabilitiesService.Deconstruct(out bool IsStateful) -> void *REMOVED*Microsoft.Testing.Platform.Services.ClientCapabilitiesService.IsStateful.init -> void ->>>>>>> Address phased cancellation review feedback diff --git a/src/Platform/Microsoft.Testing.Platform/PublicAPI/PublicAPI.Unshipped.txt b/src/Platform/Microsoft.Testing.Platform/PublicAPI/PublicAPI.Unshipped.txt index 7dc5c58110..ff6e37006a 100644 --- a/src/Platform/Microsoft.Testing.Platform/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/Platform/Microsoft.Testing.Platform/PublicAPI/PublicAPI.Unshipped.txt @@ -1 +1,3 @@ #nullable enable +[TPEXP]Microsoft.Testing.Platform.Services.ClientCapabilitiesExtensions +[TPEXP]static Microsoft.Testing.Platform.Services.ClientCapabilitiesExtensions.GetIsStateful(this Microsoft.Testing.Platform.Services.IClientCapabilities! capabilities) -> bool? diff --git a/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/Json/Json.Deserializers.cs b/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/Json/Json.Deserializers.cs index fab3b33dc0..c67c4a749f 100644 --- a/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/Json/Json.Deserializers.cs +++ b/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/Json/Json.Deserializers.cs @@ -189,9 +189,15 @@ or JsonRpcMethods.TestingRunTests { jsonElement.TryGetProperty(JsonRpcStrings.Testing, out JsonElement testing); - bool isStateful = testing.ValueKind == JsonValueKind.Object - && testing.TryGetProperty(JsonRpcStrings.IsStateful, out JsonElement statefulElement) - && statefulElement.ValueKind == JsonValueKind.True; + bool? isStateful = testing.ValueKind != JsonValueKind.Object + || !testing.TryGetProperty(JsonRpcStrings.IsStateful, out JsonElement statefulElement) + ? null + : statefulElement.ValueKind switch + { + JsonValueKind.True => true, + JsonValueKind.False => false, + _ => throw new MessageFormatException($"'{JsonRpcStrings.IsStateful}' field has wrong type (expected {nameof(Boolean)})"), + }; return new ClientCapabilities( DebuggerProvider: json.Bind(testing, JsonRpcStrings.DebuggerProvider), diff --git a/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/Json/Json.TestNodeSerializer.cs b/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/Json/Json.TestNodeSerializer.cs index 07a0545b92..b14de61242 100644 --- a/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/Json/Json.TestNodeSerializer.cs +++ b/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/Json/Json.TestNodeSerializer.cs @@ -129,10 +129,13 @@ private static (string Name, object? Value)[] BuildTestNodeProperties(TestNode m { properties.Add(("execution-state", "failed")); Exception? exception = failedTestNodeStateProperty.Exception; - properties.Add(("error.message", failedTestNodeStateProperty.Explanation ?? exception?.Message)); - if (exception is not null) + (string? errorMessage, string? errorStackTrace) = SerializerUtilities.FormatException( + failedTestNodeStateProperty.Explanation, + exception); + properties.Add(("error.message", errorMessage)); + if (errorStackTrace is not null) { - properties.Add(("error.stacktrace", exception.StackTrace ?? string.Empty)); + properties.Add(("error.stacktrace", errorStackTrace)); } // AssertionFailureProperty is the supported channel; Exception.Data is the legacy @@ -155,11 +158,13 @@ private static (string Name, object? Value)[] BuildTestNodeProperties(TestNode m case TimeoutTestNodeStateProperty timeoutTestNodeStateProperty: { properties.Add(("execution-state", "timed-out")); - Exception? exception = timeoutTestNodeStateProperty.Exception; - properties.Add(("error.message", timeoutTestNodeStateProperty.Explanation ?? exception?.Message)); - if (exception is not null) + (string? errorMessage, string? errorStackTrace) = SerializerUtilities.FormatException( + timeoutTestNodeStateProperty.Explanation, + timeoutTestNodeStateProperty.Exception); + properties.Add(("error.message", errorMessage)); + if (errorStackTrace is not null) { - properties.Add(("error.stacktrace", exception.StackTrace ?? string.Empty)); + properties.Add(("error.stacktrace", errorStackTrace)); } break; @@ -168,11 +173,13 @@ private static (string Name, object? Value)[] BuildTestNodeProperties(TestNode m case ErrorTestNodeStateProperty errorTestNodeStateProperty: { properties.Add(("execution-state", "error")); - Exception? exception = errorTestNodeStateProperty.Exception; - properties.Add(("error.message", errorTestNodeStateProperty.Explanation ?? exception?.Message)); - if (exception is not null) + (string? errorMessage, string? errorStackTrace) = SerializerUtilities.FormatException( + errorTestNodeStateProperty.Explanation, + errorTestNodeStateProperty.Exception); + properties.Add(("error.message", errorMessage)); + if (errorStackTrace is not null) { - properties.Add(("error.stacktrace", exception.StackTrace ?? string.Empty)); + properties.Add(("error.stacktrace", errorStackTrace)); } break; @@ -183,11 +190,13 @@ private static (string Name, object? Value)[] BuildTestNodeProperties(TestNode m #pragma warning restore CS0618, MTP0001 // Type or member is obsolete { properties.Add(("execution-state", "canceled")); - Exception? exception = canceledTestNodeStateProperty.Exception; - properties.Add(("error.message", canceledTestNodeStateProperty.Explanation ?? exception?.Message)); - if (exception is not null) + (string? errorMessage, string? errorStackTrace) = SerializerUtilities.FormatException( + canceledTestNodeStateProperty.Explanation, + canceledTestNodeStateProperty.Exception); + properties.Add(("error.message", errorMessage)); + if (errorStackTrace is not null) { - properties.Add(("error.stacktrace", exception.StackTrace ?? string.Empty)); + properties.Add(("error.stacktrace", errorStackTrace)); } break; diff --git a/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/RpcMessages.cs b/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/RpcMessages.cs index 557f56e1d8..068c807eab 100644 --- a/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/RpcMessages.cs +++ b/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/RpcMessages.cs @@ -108,7 +108,7 @@ internal sealed record InvalidRequestParamsArgs(int ErrorCode, string ErrorMessa internal sealed record ClientInfo(string Name, string Version); -internal sealed record ClientCapabilities(bool DebuggerProvider, bool IsStateful); +internal sealed record ClientCapabilities(bool DebuggerProvider, bool? IsStateful); internal sealed record ServerInfo(string Name, string Version); diff --git a/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/SerializerUtilities.Deserializers.cs b/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/SerializerUtilities.Deserializers.cs index f470cab938..7a305c2917 100644 --- a/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/SerializerUtilities.Deserializers.cs +++ b/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/SerializerUtilities.Deserializers.cs @@ -146,7 +146,11 @@ or JsonRpcMethods.TestingRunTests IDictionary capabilities = GetRequiredPropertyFromJson>(properties, JsonRpcStrings.Capabilities); IDictionary testingCapabilities = GetRequiredPropertyFromJson>(capabilities, JsonRpcStrings.Testing); bool debuggerProvider = GetRequiredPropertyFromJson(testingCapabilities, JsonRpcStrings.DebuggerProvider); - bool isStateful = GetOptionalPropertyFromJson(testingCapabilities, JsonRpcStrings.IsStateful) as bool? ?? false; + bool? isStateful = testingCapabilities.TryGetValue(JsonRpcStrings.IsStateful, out object? isStatefulValue) + ? isStatefulValue is bool value + ? value + : throw new MessageFormatException($"'{JsonRpcStrings.IsStateful}' field has wrong type (expected {nameof(Boolean)})") + : null; return new ClientCapabilities(debuggerProvider, isStateful); }); diff --git a/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/SerializerUtilities.TestNodeSerializers.cs b/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/SerializerUtilities.TestNodeSerializers.cs index 8106bbb582..29cc0c3b38 100644 --- a/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/SerializerUtilities.TestNodeSerializers.cs +++ b/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/SerializerUtilities.TestNodeSerializers.cs @@ -7,6 +7,7 @@ using Jsonite; #endif using Microsoft.Testing.Platform.Extensions.Messages; +using Microsoft.Testing.Platform.OutputDevice.Terminal; namespace Microsoft.Testing.Platform.ServerMode; @@ -188,11 +189,14 @@ private static void RegisterTestNodeSerializers() case FailedTestNodeStateProperty failedTestNodeStateProperty: { properties["execution-state"] = "failed"; - properties["error.message"] = failedTestNodeStateProperty.Explanation ?? failedTestNodeStateProperty.Exception?.Message; Exception? exception = failedTestNodeStateProperty.Exception; - if (exception is not null) + (string? errorMessage, string? errorStackTrace) = FormatException( + failedTestNodeStateProperty.Explanation, + exception); + properties["error.message"] = errorMessage; + if (errorStackTrace is not null) { - properties["error.stacktrace"] = exception.StackTrace ?? string.Empty; + properties["error.stacktrace"] = errorStackTrace; } // AssertionFailureProperty is the supported channel; Exception.Data is the @@ -215,10 +219,13 @@ private static void RegisterTestNodeSerializers() case TimeoutTestNodeStateProperty timeoutTestNodeStateProperty: { properties["execution-state"] = "timed-out"; - properties["error.message"] = timeoutTestNodeStateProperty.Explanation ?? timeoutTestNodeStateProperty.Exception?.Message; - if (timeoutTestNodeStateProperty.Exception is not null) + (string? errorMessage, string? errorStackTrace) = FormatException( + timeoutTestNodeStateProperty.Explanation, + timeoutTestNodeStateProperty.Exception); + properties["error.message"] = errorMessage; + if (errorStackTrace is not null) { - properties["error.stacktrace"] = timeoutTestNodeStateProperty.Exception.StackTrace ?? string.Empty; + properties["error.stacktrace"] = errorStackTrace; } break; @@ -227,10 +234,13 @@ private static void RegisterTestNodeSerializers() case ErrorTestNodeStateProperty errorTestNodeStateProperty: { properties["execution-state"] = "error"; - properties["error.message"] = errorTestNodeStateProperty.Explanation ?? errorTestNodeStateProperty.Exception?.Message; - if (errorTestNodeStateProperty.Exception is not null) + (string? errorMessage, string? errorStackTrace) = FormatException( + errorTestNodeStateProperty.Explanation, + errorTestNodeStateProperty.Exception); + properties["error.message"] = errorMessage; + if (errorStackTrace is not null) { - properties["error.stacktrace"] = errorTestNodeStateProperty.Exception.StackTrace ?? string.Empty; + properties["error.stacktrace"] = errorStackTrace; } break; @@ -241,10 +251,13 @@ private static void RegisterTestNodeSerializers() #pragma warning restore CS0618, MTP0001 // Type or member is obsolete { properties["execution-state"] = "canceled"; - properties["error.message"] = canceledTestNodeStateProperty.Explanation ?? canceledTestNodeStateProperty.Exception?.Message; - if (canceledTestNodeStateProperty.Exception is not null) + (string? errorMessage, string? errorStackTrace) = FormatException( + canceledTestNodeStateProperty.Explanation, + canceledTestNodeStateProperty.Exception); + properties["error.message"] = errorMessage; + if (errorStackTrace is not null) { - properties["error.stacktrace"] = canceledTestNodeStateProperty.Exception.StackTrace ?? string.Empty; + properties["error.stacktrace"] = errorStackTrace; } break; @@ -301,4 +314,51 @@ private static void RegisterTestNodeSerializers() return properties; }); } + + internal static (string? Message, string? StackTrace) FormatException(string? explanation, Exception? exception) + { + if (exception is null) + { + return (explanation, null); + } + + FlatException[] exceptions = ExceptionFlattener.Flatten(null, exception); + if (exceptions.Length == 1) + { + return (explanation ?? exception.Message, exception.StackTrace ?? string.Empty); + } + + StringBuilder message = new(explanation ?? exception.Message); + StringBuilder stackTrace = new(exception.StackTrace ?? string.Empty); + for (int i = 1; i < exceptions.Length; i++) + { + FlatException innerException = exceptions[i]; + if (message.Length > 0) + { + message.AppendLine(); + } + + message + .Append(" ---> ") + .Append(innerException.ErrorType) + .Append(": ") + .Append(innerException.ErrorMessage); + + if (!RoslynString.IsNullOrEmpty(innerException.StackTrace)) + { + if (stackTrace.Length > 0) + { + stackTrace.AppendLine(); + } + + stackTrace + .Append("--- Inner exception stack trace (") + .Append(innerException.ErrorType) + .AppendLine(") ---") + .Append(innerException.StackTrace); + } + } + + return (message.ToString(), stackTrace.ToString()); + } } diff --git a/src/Platform/Microsoft.Testing.Platform/Services/ClientCapabilitiesService.cs b/src/Platform/Microsoft.Testing.Platform/Services/ClientCapabilitiesService.cs index 9222518814..1428c5cc04 100644 --- a/src/Platform/Microsoft.Testing.Platform/Services/ClientCapabilitiesService.cs +++ b/src/Platform/Microsoft.Testing.Platform/Services/ClientCapabilitiesService.cs @@ -3,4 +3,7 @@ namespace Microsoft.Testing.Platform.Services; -internal sealed record ClientCapabilitiesService(bool IsStateful) : IClientCapabilities; +internal sealed record ClientCapabilitiesService(bool? DeclaredIsStateful) : IClientCapabilities +{ + public bool IsStateful => DeclaredIsStateful ?? false; +} diff --git a/src/Platform/Microsoft.Testing.Platform/Services/IClientCapabilities.cs b/src/Platform/Microsoft.Testing.Platform/Services/IClientCapabilities.cs index 6faf893b4a..0491492dc8 100644 --- a/src/Platform/Microsoft.Testing.Platform/Services/IClientCapabilities.cs +++ b/src/Platform/Microsoft.Testing.Platform/Services/IClientCapabilities.cs @@ -8,8 +8,8 @@ namespace Microsoft.Testing.Platform.Services; /// /// /// Capabilities are opt-in: unless a client explicitly declares a capability, the platform assumes the -/// most conservative (default) behavior. This lets a test framework tailor its behavior to how the client -/// intends to consume the results without having to guess based on the environment or transport. +/// most conservative (default) behavior. Use +/// when the distinction between an explicitly stateless client and an undeclared capability is required. /// /// This API is experimental. It may change, break, or be removed at any time without notice. /// @@ -29,3 +29,27 @@ public interface IClientCapabilities /// bool IsStateful { get; } } + +/// +/// Provides extension methods for . +/// +/// +/// This API is experimental. It may change, break, or be removed at any time without notice. +/// +[Experimental("TPEXP", UrlFormat = "https://aka.ms/testingplatform/diagnostics#{0}")] +public static class ClientCapabilitiesExtensions +{ + /// + /// Gets a value indicating whether the client is stateful, or when the client + /// did not declare the capability. + /// + /// The client capabilities. + /// + /// for a stateful client, for a client that explicitly declares + /// itself stateless, or when the capability was not declared. + /// + public static bool? GetIsStateful(this IClientCapabilities capabilities) + => capabilities is ClientCapabilitiesService clientCapabilities + ? clientCapabilities.DeclaredIsStateful + : capabilities.IsStateful; +} diff --git a/test/IntegrationTests/MSTest.Acceptance.IntegrationTests/SourceGenerationNonAotTests.cs b/test/IntegrationTests/MSTest.Acceptance.IntegrationTests/SourceGenerationNonAotTests.cs index a1acee3eee..737bb0a3f7 100644 --- a/test/IntegrationTests/MSTest.Acceptance.IntegrationTests/SourceGenerationNonAotTests.cs +++ b/test/IntegrationTests/MSTest.Acceptance.IntegrationTests/SourceGenerationNonAotTests.cs @@ -190,7 +190,7 @@ public async Task SourceGenerationNonAot_BuildsAndRunsTests_WithExitCodeZero(str string registration = File.ReadAllText(generatedFiles.Single(path => path.EndsWith("MSTestReflectionMetadata.Registration.g.cs", StringComparison.Ordinal))); StringAssert.Contains(registration, "availableMethods ??= type.GetMethods(memberFlags)"); - StringAssert.Contains(registration, "ResolveMethod(availableMethods, method.Name, method.ParameterTypes)"); + StringAssert.Contains(registration, "ResolveMethod(availableMethods, method.DeclaringType, method.Name, method.ParameterTypes)"); StringAssert.Contains(registration, "methodInfo.GetCustomAttributes(typeof(AsyncStateMachineAttribute), inherit: false)"); StringAssert.Contains(registration, "methodInfo.GetCustomAttributes(typeof(DebuggerStepThroughAttribute), inherit: false)"); StringAssert.Contains(registration, "descriptorTestMethods[type] = descriptorMethodRoots.ToArray()"); diff --git a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/ServerMode/v1.0.0/ClientCapabilities.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/ServerMode/v1.0.0/ClientCapabilities.cs index c463045a3b..7b8071cd49 100644 --- a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/ServerMode/v1.0.0/ClientCapabilities.cs +++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/ServerMode/v1.0.0/ClientCapabilities.cs @@ -13,5 +13,5 @@ public sealed record ClientTestingCapabilities( [property: JsonProperty("debuggerProvider")] bool DebuggerProvider, - [property: JsonProperty("isStateful")] - bool IsStateful = false); + [property: JsonProperty("isStateful", NullValueHandling = NullValueHandling.Ignore)] + bool? IsStateful = null); diff --git a/test/UnitTests/MSTest.Analyzers.UnitTests/ReviewAlwaysTrueAssertConditionAnalyzerTests.cs b/test/UnitTests/MSTest.Analyzers.UnitTests/ReviewAlwaysTrueAssertConditionAnalyzerTests.cs index c93cecb7bd..d757e147e1 100644 --- a/test/UnitTests/MSTest.Analyzers.UnitTests/ReviewAlwaysTrueAssertConditionAnalyzerTests.cs +++ b/test/UnitTests/MSTest.Analyzers.UnitTests/ReviewAlwaysTrueAssertConditionAnalyzerTests.cs @@ -940,6 +940,150 @@ public void TestMethod() await VerifyCS.VerifyCodeFixAsync(code, code); } + [TestMethod] + public async Task WhenAssertAreEqualPinsEnumUnderlyingValue_NoDiagnostic() + { + string code = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + public enum ReportDumpType + { + Micro = 1, + Mini = 2, + Heap = 3, + All = -1, + } + + [TestClass] + public class MyTestClass + { + [TestMethod] + public void TestMethod() + { + Assert.AreEqual(1, (int)ReportDumpType.Micro); + Assert.AreEqual((int)ReportDumpType.Mini, 2); + Assert.AreEqual(actual: (int)ReportDumpType.Heap, expected: 3); + Assert.AreEqual(-1, (int)ReportDumpType.All); + Assert.AreEqual(+1, (int)ReportDumpType.Micro); + Assert.AreEqual((1), ((int)ReportDumpType.Micro)); + } + } + """; + + await VerifyCS.VerifyCodeFixAsync(code, code); + } + + [TestMethod] + public async Task WhenAssertAreEqualPinsEnumsWithAllUnderlyingTypes_NoDiagnostic() + { + string code = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + public enum SByteEnum : sbyte { Value = 1 } + public enum ByteEnum : byte { Value = 1 } + public enum ShortEnum : short { Value = 1 } + public enum UShortEnum : ushort { Value = 1 } + public enum IntEnum : int { Value = 1 } + public enum UIntEnum : uint { Value = 1 } + public enum LongEnum : long { Value = 1 } + public enum ULongEnum : ulong { Value = 1 } + + [TestClass] + public class MyTestClass + { + [TestMethod] + public void TestMethod() + { + Assert.AreEqual(1, (sbyte)SByteEnum.Value); + Assert.AreEqual(1, (byte)ByteEnum.Value); + Assert.AreEqual(1, (short)ShortEnum.Value); + Assert.AreEqual(1, (ushort)UShortEnum.Value); + Assert.AreEqual(1, (int)IntEnum.Value); + Assert.AreEqual(1U, (uint)UIntEnum.Value); + Assert.AreEqual(1L, (long)LongEnum.Value); + Assert.AreEqual(1UL, (ulong)ULongEnum.Value); + } + } + """; + + await VerifyCS.VerifyCodeFixAsync(code, code); + } + + [TestMethod] + public async Task WhenAssertAreEqualUsesOtherConstantExpressions_Diagnostic() + { + string code = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + [TestClass] + public class MyTestClass + { + private const int Field = 1; + + [TestMethod] + public void TestMethod() + { + const int local = 1; + + [|Assert.AreEqual(1, 1)|]; + [|Assert.AreEqual(-1, -1)|]; + [|Assert.AreEqual(+1, +1)|]; + [|Assert.AreEqual((byte)1, (byte)1)|]; + [|Assert.AreEqual(1, (int)1)|]; + [|Assert.AreEqual((int)1, (int)1)|]; + [|Assert.AreEqual(local, 1)|]; + [|Assert.AreEqual(Field, 1)|]; + [|Assert.AreEqual(1 + 1, 2)|]; + [|Assert.AreEqual('a', 'a')|]; + [|Assert.AreEqual(1.0, 1.0)|]; + [|Assert.AreEqual(1m, 1m)|]; + [|Assert.AreEqual("value", "value")|]; + } + } + """; + + await VerifyCS.VerifyCodeFixAsync(code, code); + } + + [TestMethod] + public async Task WhenAssertAreEqualUsesOtherEnumConstantExpressions_Diagnostic() + { + string code = """ + using System; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + [Flags] + public enum Options + { + None = 0, + First = 1, + Second = 2, + } + + [TestClass] + public class MyTestClass + { + private const Options Field = Options.First; + + [TestMethod] + public void TestMethod() + { + const Options local = Options.First; + + [|Assert.AreEqual(Options.First, Options.First)|]; + [|Assert.AreEqual((int)Options.First, (int)Options.First)|]; + [|Assert.AreEqual(1, (int)local)|]; + [|Assert.AreEqual(1, (int)Field)|]; + [|Assert.AreEqual(3, (int)(Options.First | Options.Second))|]; + [|Assert.AreEqual(1, (long)Options.First)|]; + [|Assert.AreEqual((int)1, (int)Options.First)|]; + } + } + """; + + await VerifyCS.VerifyCodeFixAsync(code, code); + } + [TestMethod] public async Task WhenAssertAreEqualIsPassedEqual_WithMessage_Diagnostic() { diff --git a/test/UnitTests/MSTest.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs b/test/UnitTests/MSTest.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs index 72b3b777b6..07f6238c8c 100644 --- a/test/UnitTests/MSTest.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs +++ b/test/UnitTests/MSTest.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs @@ -114,11 +114,12 @@ public readonly struct ConstructorInvokerInfo public static class ReflectionMetadataHook { + public static System.Collections.Generic.IReadOnlyDictionary? RegisteredTestMethods { get; private set; } public static void Register(System.Reflection.Assembly assembly, System.Type[] types, System.Collections.Generic.IReadOnlyDictionary testMethods) { } public static void Register(System.Reflection.Assembly assembly, System.Type[] types, System.Collections.Generic.IReadOnlyDictionary testMethods, System.Collections.Generic.IReadOnlyDictionary typeAttributes, object[] assemblyAttributes) { } public static void Register(System.Reflection.Assembly assembly, System.Type[] types, System.Collections.Generic.IReadOnlyDictionary testMethods, System.Collections.Generic.IReadOnlyDictionary typeAttributes, object[] assemblyAttributes, System.Collections.Generic.IReadOnlyDictionary> methodInvokers, System.Collections.Generic.IReadOnlyDictionary constructorInvokers, System.Collections.Generic.IReadOnlyDictionary> propertySetters) { } public static void Register(System.Reflection.Assembly assembly, System.Type[] types, System.Collections.Generic.IReadOnlyDictionary testMethods, System.Collections.Generic.IReadOnlyDictionary typeAttributes, object[] assemblyAttributes, System.Collections.Generic.IReadOnlyDictionary methodAttributes, System.Collections.Generic.IReadOnlyDictionary> methodInvokers, System.Collections.Generic.IReadOnlyDictionary constructorInvokers, System.Collections.Generic.IReadOnlyDictionary> propertySetters) { } - public static void Register(System.Reflection.Assembly assembly, System.Type[] types, System.Collections.Generic.IReadOnlyDictionary testMethods, System.Collections.Generic.IReadOnlyDictionary typeAttributes, object[] assemblyAttributes, System.Collections.Generic.IReadOnlyDictionary methodAttributes, System.Collections.Generic.IReadOnlyDictionary> methodInvokers, System.Collections.Generic.IReadOnlyDictionary constructorInvokers, System.Collections.Generic.IReadOnlyDictionary> propertySetters, System.Collections.Generic.IReadOnlyDictionary descriptorTestMethods, System.Type[] descriptorCompleteTypes) { } + public static void Register(System.Reflection.Assembly assembly, System.Type[] types, System.Collections.Generic.IReadOnlyDictionary testMethods, System.Collections.Generic.IReadOnlyDictionary typeAttributes, object[] assemblyAttributes, System.Collections.Generic.IReadOnlyDictionary methodAttributes, System.Collections.Generic.IReadOnlyDictionary> methodInvokers, System.Collections.Generic.IReadOnlyDictionary constructorInvokers, System.Collections.Generic.IReadOnlyDictionary> propertySetters, System.Collections.Generic.IReadOnlyDictionary descriptorTestMethods, System.Type[] descriptorCompleteTypes) { RegisteredTestMethods = testMethods; } } } """; @@ -1256,6 +1257,235 @@ public void Test() { } registry.Should().Contain("Name = \"ProtectedInternalContext\""); } + [TestMethod] + public void Generator_ExcludesInaccessibleMembersFromBaseTypeInAnotherAssembly() + { + const string baseCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + public class GrandparentTests + { + [TestMethod] + public void HiddenTest() { } + + [TestMethod] + public void PropertyHidesMethod() { } + + [TestMethod] + public void StaticHidesInstance() { } + + [TestMethod] + public void InaccessibleOverloadHidesMethodGroup(int value) { } + + [TestContext] + public int HiddenContext { get; set; } + + public int MethodHidesProperty { [TestMethod] get; set; } + } + + public class BaseTests : GrandparentTests + { + [TestMethod] + public void PublicInheritedTest() { } + + [TestMethod] + protected internal void InheritedTest() { } + + [TestMethod] + internal void InternalTest() { } + + protected internal new void HiddenTest() { } + + protected internal int PropertyHidesMethod { get; set; } + + protected internal static new void StaticHidesInstance() { } + + protected internal void InaccessibleOverloadHidesMethodGroup(string value) { } + + [TestContext] + public int PublicContext { get; set; } + + [TestContext] + protected internal int InaccessibleContext { get; set; } + + [TestContext] + internal int InternalContext { get; set; } + + protected internal new int HiddenContext { get; set; } + + protected internal void MethodHidesProperty() { } + + public int ContextWithInaccessibleGetter { protected internal get; set; } + + public int ContextWithInternalGetter { internal get; set; } + } + """; + + CSharpCompilation baseCompilation = CreateCompilation(MinimalMSTestStub, baseCode) + .WithAssemblyName("BaseAssembly"); + using var stream = new MemoryStream(); + baseCompilation.Emit(stream).Success.Should().BeTrue(); + MetadataReference baseReference = MetadataReference.CreateFromImage(stream.ToArray()); + + const string consumerCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + [TestClass] + public class DerivedTests : BaseTests { } + """; + + CSharpCompilation consumerCompilation = CreateCompilation(consumerCode); + consumerCompilation = consumerCompilation + .WithOptions(consumerCompilation.Options.WithMetadataImportOptions(MetadataImportOptions.All)) + .AddReferences(baseReference); + GeneratorDriver driver = CreateDriver(consumerCompilation); + driver.RunGeneratorsAndUpdateCompilation(consumerCompilation, out Compilation outputCompilation, out _); + string registry = outputCompilation.SyntaxTrees + .Single(t => t.FilePath.EndsWith("MSTestReflectionMetadata.Registry.g.cs", StringComparison.Ordinal)) + .ToString(); + + registry.Should().NotContain("Name = \"InheritedTest\""); + registry.Should().NotContain("Name = \"InternalTest\""); + registry.Should().Contain("Name = \"PublicInheritedTest\""); + registry.Should().NotContain("Name = \"InaccessibleContext\""); + registry.Should().NotContain("Name = \"InternalContext\""); + registry.Should().Contain("Name = \"PublicContext\""); + registry.Should().NotContain("Name = \"HiddenTest\""); + registry.Should().NotContain("Name = \"HiddenContext\""); + registry.Should().NotContain("Name = \"PropertyHidesMethod\""); + registry.Should().NotContain("Name = \"StaticHidesInstance\""); + registry.Should().NotContain("Name = \"InaccessibleOverloadHidesMethodGroup\""); + registry.Should().NotContain("Name = \"MethodHidesProperty\""); + registry.Should().Contain("Property 'ContextWithInaccessibleGetter' has no accessible getter."); + registry.Should().Contain("Property 'ContextWithInternalGetter' has no accessible getter."); + outputCompilation.GetDiagnostics() + .Where(d => d.Severity == DiagnosticSeverity.Error) + .Should().BeEmpty(); + } + + [TestMethod] + public void Generator_PropertyHidingTestMethod_MarksDescriptorsIncomplete() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + public class BaseTests + { + [TestMethod] + public void Hidden() { } + } + + [TestClass] + public class DerivedTests : BaseTests + { + public new int Hidden { get; set; } + } + """; + + string registry = GetRegistry(RunGenerator(MinimalMSTestStub, userCode)); + + registry.Should().Contain("AreGeneratedDescriptorsComplete = false"); + registry.Should().NotContain("((global::DerivedTests)instance!).Hidden();"); + } + + [TestMethod] + public void Generator_NonMethodMembersHideBaseMethods() + { + const string userCode = """ + using System; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + public class BaseTests + { + [TestMethod] + public void FieldHidden() { } + + [TestMethod] + public void EventHidden() { } + + [TestMethod] + public void TypeHidden() { } + } + + [TestClass] + public class DerivedTests : BaseTests + { + public int FieldHidden; + + public event Action? EventHidden; + + public class TypeHidden { } + } + """; + + Compilation outputCompilation = RunGeneratorAndGetCompilation(MinimalMSTestStub, userCode); + string registry = outputCompilation.SyntaxTrees + .Single(t => t.FilePath.EndsWith("MSTestReflectionMetadata.Registry.g.cs", StringComparison.Ordinal)) + .ToString(); + + registry.Should().Contain("AreGeneratedDescriptorsComplete = false"); + registry.Should().NotContain("((global::DerivedTests)instance!).FieldHidden();"); + registry.Should().NotContain("((global::DerivedTests)instance!).EventHidden();"); + registry.Should().NotContain("((global::DerivedTests)instance!).TypeHidden();"); + outputCompilation.GetDiagnostics() + .Where(d => d.Severity == DiagnosticSeverity.Error) + .Should().BeEmpty(); + } + + [TestMethod] + public void Generator_MethodHidingTestAttributedPropertyAccessor_MarksDescriptorsIncomplete() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + public class BaseTests + { + public int Hidden { [TestMethod] get; set; } + } + + [TestClass] + public class DerivedTests : BaseTests + { + public new void Hidden() { } + } + """; + + string registry = GetRegistry(RunGenerator(MinimalMSTestStub, userCode)); + + registry.Should().Contain("AreGeneratedDescriptorsComplete = false"); + registry.Should().NotContain("PropertyType = typeof(int)"); + } + + [TestMethod] + public void Generator_HiddenUnsupportedInheritedMethods_ReportDiagnostics() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + public class BaseTests + { + [TestMethod] + public void GenericHidden() { } + + [TestMethod] + public void ByRefHidden(ref int value) { } + } + + [TestClass] + public class DerivedTests : BaseTests + { + public int GenericHidden { get; set; } + + public int ByRefHidden { get; set; } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().ContainSingle(d => d.Id == "AOTSG0004"); + result.Diagnostics.Should().ContainSingle(d => d.Id == "AOTSG0005"); + } + [TestMethod] public void Generator_IncludesMethodsFromMultiLevelInheritance() { @@ -1321,6 +1551,7 @@ public override void Run() { } runEntries.Should().Be(1, "the derived override must replace the base entry (not duplicate it)"); registry.Should().Contain("((global::Sample.DerivedTests)instance!).Run();"); registry.Should().NotContain("((global::Sample.BaseTests)instance!).Run();"); + registry.Should().Contain("AreGeneratedDescriptorsComplete = true"); // TestMethodAttribute is not inherited, so the override should not pick up the base attribute. registry.Should().NotContain("global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute"); @@ -1388,10 +1619,221 @@ public class DerivedTests : BaseTests int hiddenEntries = registry.Split(["Name = \"Hidden\""], System.StringSplitOptions.None).Length - 1; hiddenEntries.Should().Be(1, "members with the same name and signature must be de-duplicated; derived wins"); registry.Should().Contain("((global::Sample.DerivedTests)instance!).Hidden();"); + registry.Should().Contain("AreGeneratedDescriptorsComplete = true"); + } + + [TestMethod] + public void Generator_PrivateSameSignatureMethod_DoesNotReplaceInheritedTest() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + public class BaseTests + { + [TestMethod] + public void Hidden() { } + } + + [TestClass] + public class DerivedTests : BaseTests + { + private new void Hidden() { } + } + """; + + string registry = GetRegistry(RunGenerator(MinimalMSTestStub, userCode)); + + registry.Should().Contain("AreGeneratedDescriptorsComplete = false"); + registry.Should().NotContain("((global::DerivedTests)instance!).Hidden();"); + } + + [TestMethod] + public void Generator_PublicNonTestSameSignatureMethod_DoesNotReplaceInheritedTest() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + public class BaseTests + { + [TestMethod] + public void Hidden() { } + } + + [TestClass] + public class DerivedTests : BaseTests + { + public new void Hidden() { } + } + """; + + string registry = GetRegistry(RunGenerator(MinimalMSTestStub, userCode)); + + registry.Should().Contain("AreGeneratedDescriptorsComplete = false"); + registry.Should().Contain("((global::DerivedTests)instance!).Hidden();"); + } + + [TestMethod] + public void Generator_InstanceTestMethod_ReplacesStaticAncestor() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + public class BaseTests + { + [TestMethod] + public static void Hidden() { } + } + + [TestClass] + public class DerivedTests : BaseTests + { + [TestMethod] + public new void Hidden() { } + } + """; + + string registry = GetRegistry(RunGenerator(MinimalMSTestStub, userCode)); + + registry.Should().Contain("AreGeneratedDescriptorsComplete = true"); + registry.Should().Contain("((global::DerivedTests)instance!).Hidden();"); + } + + [TestMethod] + public void Generator_DynamicTestMethod_ReplacesObjectAncestor() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + public class BaseTests + { + [TestMethod] + public void Hidden(object value) { } + } + + [TestClass] + public class DerivedTests : BaseTests + { + [TestMethod] + public new void Hidden(dynamic value) { } + } + """; + + string registry = GetRegistry(RunGenerator(MinimalMSTestStub, userCode)); + + registry.Should().Contain("AreGeneratedDescriptorsComplete = true"); + registry.Should().Contain("((global::DerivedTests)instance!).Hidden((dynamic)args![0]!);"); + } + + [TestMethod] + public void RuntimeSignature_DynamicAndObjectReturnTypes_AreEquivalent() + { + const string userCode = """ + public class DynamicReturn + { + public dynamic Method() => new object(); + } + + public class ObjectReturn + { + public object Method() => new object(); + } + """; + + CSharpCompilation compilation = CreateCompilation(userCode); + var dynamicMethod = (IMethodSymbol)compilation.GetTypeByMetadataName("DynamicReturn")!.GetMembers("Method").Single(); + var objectMethod = (IMethodSymbol)compilation.GetTypeByMetadataName("ObjectReturn")!.GetMembers("Method").Single(); + + TestMemberValidationHelper.HaveSameRuntimeDiscoverySignature(dynamicMethod, objectMethod).Should().BeTrue(); } [TestMethod] - public void Generator_OverloadsWithDifferentSignatures_AreAllPreserved() + public void RuntimeSignature_DifferentReturnTypes_AreNotEquivalent() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + public class BaseTests + { + [TestMethod] + public void Hidden() { } + } + + [TestClass] + public class DerivedTests : BaseTests + { + [TestMethod] + public new int Hidden() => 1; + } + """; + + CSharpCompilation compilation = CreateCompilation(userCode); + var baseMethod = (IMethodSymbol)compilation.GetTypeByMetadataName("BaseTests")!.GetMembers("Hidden").Single(); + var derivedMethod = (IMethodSymbol)compilation.GetTypeByMetadataName("DerivedTests")!.GetMembers("Hidden").Single(); + + TestMemberValidationHelper.HaveSameRuntimeDiscoverySignature(derivedMethod, baseMethod).Should().BeFalse(); + GetRegistry(RunGenerator(MinimalMSTestStub, userCode)) + .Should().Contain("AreGeneratedDescriptorsComplete = false"); + } + + [TestMethod] + public void RuntimeSignature_DifferentlyNamedMethodTypeParameters_AreNotEquivalent() + { + const string userCode = """ + public class First + { + public void Method(T value) { } + } + + public class Second + { + public void Method(U value) { } + } + """; + + CSharpCompilation compilation = CreateCompilation(userCode); + var firstMethod = (IMethodSymbol)compilation.GetTypeByMetadataName("First")!.GetMembers("Method").Single(); + var secondMethod = (IMethodSymbol)compilation.GetTypeByMetadataName("Second")!.GetMembers("Method").Single(); + + TestMemberValidationHelper.HaveSameRuntimeDiscoverySignature(firstMethod, secondMethod).Should().BeFalse(); + } + + [TestMethod] + public void Generator_NestedGenericContainingTypeSubstitutions_HaveDistinctRuntimeSignatures() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + public class Outer + { + public class Middle + { + public class Inner { } + } + } + + public class BaseTests + { + [TestMethod] + public void Hidden(Outer.Middle.Inner value) { } + } + + [TestClass] + public class DerivedTests : BaseTests + { + [TestMethod] + public new void Hidden(Outer.Middle.Inner value) { } + } + """; + + string registry = GetRegistry(RunGenerator(MinimalMSTestStub, userCode)); + + registry.Should().Contain("AreGeneratedDescriptorsComplete = false"); + registry.Should().Contain("typeof(global::Outer.Middle.Inner)"); + registry.Should().NotContain("typeof(global::Outer.Middle.Inner)"); + } + + [TestMethod] + public void Generator_DerivedMethodGroup_HidesBaseOverloads() { const string userCode = """ using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -1413,13 +1855,91 @@ public void Op(string x) { } } """; - string registry = GetRegistry(RunGenerator(MinimalMSTestStub, userCode)); + Compilation outputCompilation = RunGeneratorAndGetCompilation(MinimalMSTestStub, userCode); + string registry = outputCompilation.SyntaxTrees + .Single(t => t.FilePath.EndsWith("MSTestReflectionMetadata.Registry.g.cs", StringComparison.Ordinal)) + .ToString(); - // Both overloads survive — they have different signatures. int opEntries = registry.Split(["Name = \"Op\""], System.StringSplitOptions.None).Length - 1; - opEntries.Should().Be(2); - registry.Should().Contain("typeof(int)"); + opEntries.Should().Be(1); + registry.Should().Contain("AreGeneratedDescriptorsComplete = false"); registry.Should().Contain("typeof(string)"); + outputCompilation.GetDiagnostics() + .Where(d => d.Severity == DiagnosticSeverity.Error) + .Should().BeEmpty(); + } + + [TestMethod] + public void Generator_DerivedIndexer_DoesNotHideBaseItemMethod() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + public class BaseTests + { + [TestMethod] + public void Item() { } + } + + [TestClass] + public class DerivedTests : BaseTests + { + public int this[int index] => index; + } + """; + + Compilation outputCompilation = RunGeneratorAndGetCompilation(MinimalMSTestStub, userCode); + string registry = outputCompilation.SyntaxTrees + .Single(t => t.FilePath.EndsWith("MSTestReflectionMetadata.Registry.g.cs", StringComparison.Ordinal)) + .ToString(); + + registry.Should().Contain("AreGeneratedDescriptorsComplete = true"); + registry.Should().Contain("((global::DerivedTests)instance!).Item();"); + outputCompilation.GetDiagnostics() + .Where(d => d.Severity == DiagnosticSeverity.Error) + .Should().BeEmpty(); + } + + [TestMethod] + public void Generator_DerivedPropertyAccessor_DoesNotHideBaseAccessorNamedMethod() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + public class BaseTests + { + [TestMethod] + public void get_Value() { } + } + + [TestClass] + public class DerivedTests : BaseTests + { + public int Value => 1; + } + """; + + Compilation outputCompilation = RunGeneratorAndGetCompilation(MinimalMSTestStub, userCode); + string registry = outputCompilation.SyntaxTrees + .Single(t => t.FilePath.EndsWith("MSTestReflectionMetadata.Registry.g.cs", StringComparison.Ordinal)) + .ToString(); + + registry.Should().Contain("AreGeneratedDescriptorsComplete = true"); + registry.Should().Contain("((global::DerivedTests)instance!).get_Value();"); + outputCompilation.GetDiagnostics() + .Where(d => d.Severity == DiagnosticSeverity.Error) + .Should().BeEmpty(); + + using var assemblyStream = new MemoryStream(); + outputCompilation.Emit(assemblyStream).Success.Should().BeTrue(); + var assembly = System.Reflection.Assembly.Load(assemblyStream.ToArray()); + Type hookType = assembly.GetType("Microsoft.VisualStudio.TestPlatform.MSTestAdapter.PlatformServices.SourceGeneration.ReflectionMetadataHook")!; + var registeredTestMethods = (IReadOnlyDictionary)hookType + .GetProperty("RegisteredTestMethods")! + .GetValue(null)!; + System.Reflection.MethodInfo registeredMethod = registeredTestMethods.Values.SelectMany(static methods => methods).Single(); + registeredMethod.DeclaringType!.Name.Should().Be("BaseTests"); + registeredMethod.ReturnType.Should().Be(typeof(void)); } [TestMethod] @@ -3027,8 +3547,8 @@ public void NotATest() { } // matching and unresolved-member fallback over those cached arrays. registration.Should().Contain("MethodInfo[]? availableMethods = null;"); registration.Should().Contain("availableMethods ??= type.GetMethods(memberFlags);"); - registration.Should().Contain("ResolveMethod(availableMethods, method.Name, method.ParameterTypes)"); - registration.Should().Contain("private static MethodInfo? ResolveMethod(MethodInfo[] availableMethods"); + registration.Should().Contain("ResolveMethod(availableMethods, method.DeclaringType, method.Name, method.ParameterTypes)"); + registration.Should().Contain("private static MethodInfo? ResolveMethod(MethodInfo[] availableMethods, Type declaringType"); registration.Should().Contain("availableProperties ??= type.GetProperties(memberFlags);"); registration.Should().Contain("private static PropertyInfo? ResolveProperty(PropertyInfo[] availableProperties"); registration.Should().NotContain("type.GetMethods(flags)"); diff --git a/test/UnitTests/MSTestAdapter.UnitTests/MSTestRunSettingsTests.cs b/test/UnitTests/MSTestAdapter.UnitTests/MSTestRunSettingsTests.cs index 234f772280..1c5481e6d3 100644 --- a/test/UnitTests/MSTestAdapter.UnitTests/MSTestRunSettingsTests.cs +++ b/test/UnitTests/MSTestAdapter.UnitTests/MSTestRunSettingsTests.cs @@ -26,10 +26,16 @@ public void StatefulNonVisualStudioClientSetsDesignMode() public void StatelessNonVisualStudioClientDoesNotSetDesignMode() => GetDesignMode("custom-client", isStateful: false).Should().BeFalse(); - public void StatelessVisualStudioClientSetsDesignModeForBackwardCompatibility() - => GetDesignMode(WellKnownClients.VisualStudio, isStateful: false).Should().BeTrue(); + public void UndeclaredNonVisualStudioClientDoesNotSetDesignMode() + => GetDesignMode("custom-client", isStateful: null).Should().BeFalse(); - private static bool GetDesignMode(string clientId, bool isStateful) + public void UndeclaredVisualStudioClientSetsDesignModeForBackwardCompatibility() + => GetDesignMode(WellKnownClients.VisualStudio, isStateful: null).Should().BeTrue(); + + public void StatelessVisualStudioClientDoesNotSetDesignMode() + => GetDesignMode(WellKnownClients.VisualStudio, isStateful: false).Should().BeFalse(); + + private static bool GetDesignMode(string clientId, bool? isStateful) { const string RunSettingsFilePath = "settings.runsettings"; string[]? runSettingsFilePaths = [RunSettingsFilePath]; diff --git a/test/UnitTests/Microsoft.Testing.Extensions.VSTestBridge.UnitTests/ObjectModel/ObjectModelConvertersTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.VSTestBridge.UnitTests/ObjectModel/ObjectModelConvertersTests.cs index 747d7aa27b..cb57d3b49f 100644 --- a/test/UnitTests/Microsoft.Testing.Extensions.VSTestBridge.UnitTests/ObjectModel/ObjectModelConvertersTests.cs +++ b/test/UnitTests/Microsoft.Testing.Extensions.VSTestBridge.UnitTests/ObjectModel/ObjectModelConvertersTests.cs @@ -18,7 +18,7 @@ namespace Microsoft.Testing.Extensions.VSTestBridge.UnitTests.ObjectModel; [TestClass] public sealed class ObjectModelConvertersTests { - private static readonly IClientInfo ClientInfo = new ClientInfoService(WellKnownClients.VisualStudio, "1.0.0", new ClientCapabilitiesService(IsStateful: false)); + private static readonly IClientInfo ClientInfo = new ClientInfoService(WellKnownClients.VisualStudio, "1.0.0", new ClientCapabilitiesService(DeclaredIsStateful: false)); private static readonly TestProperty OriginalExecutorUriProperty = TestProperty.Register( VSTestTestNodeProperties.OriginalExecutorUriPropertyName, VSTestTestNodeProperties.OriginalExecutorUriPropertyName, diff --git a/test/UnitTests/Microsoft.Testing.Extensions.VSTestBridge.UnitTests/ObjectModel/RunSettingsPatcherTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.VSTestBridge.UnitTests/ObjectModel/RunSettingsPatcherTests.cs index 2fea36807b..0f42b2c1b4 100644 --- a/test/UnitTests/Microsoft.Testing.Extensions.VSTestBridge.UnitTests/ObjectModel/RunSettingsPatcherTests.cs +++ b/test/UnitTests/Microsoft.Testing.Extensions.VSTestBridge.UnitTests/ObjectModel/RunSettingsPatcherTests.cs @@ -26,7 +26,7 @@ public void Patch_StatefulNonVisualStudioClient_SetsDesignMode() XDocument runSettingsDocument = RunSettingsPatcher.Patch( null, _configuration.Object, - new ClientInfoService("custom-client", "1.0.0", new ClientCapabilitiesService(IsStateful: true)), + new ClientInfoService("custom-client", "1.0.0", new ClientCapabilitiesService(DeclaredIsStateful: true)), _commandLineOptions.Object); Assert.IsTrue(bool.Parse(runSettingsDocument.XPathSelectElement("RunSettings/RunConfiguration/DesignMode")!.Value)); @@ -40,32 +40,60 @@ public void Patch_StatelessNonVisualStudioClient_DoesNotSetDesignMode() XDocument runSettingsDocument = RunSettingsPatcher.Patch( null, _configuration.Object, - new ClientInfoService("custom-client", "1.0.0", new ClientCapabilitiesService(IsStateful: false)), + new ClientInfoService("custom-client", "1.0.0", new ClientCapabilitiesService(DeclaredIsStateful: false)), _commandLineOptions.Object); Assert.IsFalse(bool.Parse(runSettingsDocument.XPathSelectElement("RunSettings/RunConfiguration/DesignMode")!.Value)); } [TestMethod] - public void Patch_StatelessVisualStudioClient_SetsDesignModeForBackwardCompatibility() + public void Patch_UndeclaredNonVisualStudioClient_DoesNotSetDesignMode() { _configuration.Setup(x => x[PlatformConfigurationConstants.PlatformResultDirectory]).Returns("/PlatformResultDirectory"); XDocument runSettingsDocument = RunSettingsPatcher.Patch( null, _configuration.Object, - new ClientInfoService(WellKnownClients.VisualStudio, "1.0.0", new ClientCapabilitiesService(IsStateful: false)), + new ClientInfoService("custom-client", "1.0.0", new ClientCapabilitiesService(DeclaredIsStateful: null)), + _commandLineOptions.Object); + + Assert.IsFalse(bool.Parse(runSettingsDocument.XPathSelectElement("RunSettings/RunConfiguration/DesignMode")!.Value)); + } + + [TestMethod] + public void Patch_UndeclaredVisualStudioClient_SetsDesignModeForBackwardCompatibility() + { + _configuration.Setup(x => x[PlatformConfigurationConstants.PlatformResultDirectory]).Returns("/PlatformResultDirectory"); + + XDocument runSettingsDocument = RunSettingsPatcher.Patch( + null, + _configuration.Object, + new ClientInfoService(WellKnownClients.VisualStudio, "1.0.0", new ClientCapabilitiesService(DeclaredIsStateful: null)), _commandLineOptions.Object); Assert.IsTrue(bool.Parse(runSettingsDocument.XPathSelectElement("RunSettings/RunConfiguration/DesignMode")!.Value)); } + [TestMethod] + public void Patch_StatelessVisualStudioClient_DoesNotSetDesignMode() + { + _configuration.Setup(x => x[PlatformConfigurationConstants.PlatformResultDirectory]).Returns("/PlatformResultDirectory"); + + XDocument runSettingsDocument = RunSettingsPatcher.Patch( + null, + _configuration.Object, + new ClientInfoService(WellKnownClients.VisualStudio, "1.0.0", new ClientCapabilitiesService(DeclaredIsStateful: false)), + _commandLineOptions.Object); + + Assert.IsFalse(bool.Parse(runSettingsDocument.XPathSelectElement("RunSettings/RunConfiguration/DesignMode")!.Value)); + } + [TestMethod] public void Patch_WhenNoRunSettingsProvided_CreateRunSettingsWithResultsDirectoryElement() { _configuration.Setup(x => x[PlatformConfigurationConstants.PlatformResultDirectory]).Returns("/PlatformResultDirectory"); XDocument runSettingsDocument = RunSettingsPatcher.Patch(null, _configuration.Object, - new ClientInfoService(string.Empty, string.Empty, new ClientCapabilitiesService(IsStateful: false)), _commandLineOptions.Object); + new ClientInfoService(string.Empty, string.Empty, new ClientCapabilitiesService(DeclaredIsStateful: false)), _commandLineOptions.Object); Assert.AreEqual( "/PlatformResultDirectory", runSettingsDocument.XPathSelectElement("RunSettings/RunConfiguration/ResultsDirectory")!.Value); @@ -84,7 +112,7 @@ public void Patch_WithRunSettingsProvidedButMissingResultsDirectory_AddsElement( _configuration.Setup(x => x[PlatformConfigurationConstants.PlatformResultDirectory]).Returns("/PlatformResultDirectory"); - XDocument runSettingsDocument = RunSettingsPatcher.Patch(runSettings, _configuration.Object, new ClientInfoService(string.Empty, string.Empty, new ClientCapabilitiesService(IsStateful: false)), _commandLineOptions.Object); + XDocument runSettingsDocument = RunSettingsPatcher.Patch(runSettings, _configuration.Object, new ClientInfoService(string.Empty, string.Empty, new ClientCapabilitiesService(DeclaredIsStateful: false)), _commandLineOptions.Object); Assert.AreEqual( "/PlatformResultDirectory", runSettingsDocument.XPathSelectElement("RunSettings/RunConfiguration/ResultsDirectory")!.Value); @@ -105,7 +133,7 @@ public void Patch_WithRunSettingsContainingResultsDirectory_EntryIsNotOverridden """; _configuration.Setup(x => x[PlatformConfigurationConstants.PlatformResultDirectory]).Returns("/PlatformResultDirectory"); - XDocument runSettingsDocument = RunSettingsPatcher.Patch(runSettings, _configuration.Object, new ClientInfoService(string.Empty, string.Empty, new ClientCapabilitiesService(IsStateful: false)), _commandLineOptions.Object); + XDocument runSettingsDocument = RunSettingsPatcher.Patch(runSettings, _configuration.Object, new ClientInfoService(string.Empty, string.Empty, new ClientCapabilitiesService(DeclaredIsStateful: false)), _commandLineOptions.Object); Assert.AreEqual( "/PlatformResultDirectoryFromFile", runSettingsDocument.XPathSelectElement("RunSettings/RunConfiguration/ResultsDirectory")!.Value); @@ -134,7 +162,7 @@ public void Patch_WhenRunSettingsExists_MergesParameters() }); _configuration.Setup(x => x[PlatformConfigurationConstants.PlatformResultDirectory]).Returns("/PlatformResultDirectory"); - XDocument runSettingsDocument = RunSettingsPatcher.Patch(runSettings, _configuration.Object, new ClientInfoService(string.Empty, string.Empty, new ClientCapabilitiesService(IsStateful: false)), + XDocument runSettingsDocument = RunSettingsPatcher.Patch(runSettings, _configuration.Object, new ClientInfoService(string.Empty, string.Empty, new ClientCapabilitiesService(DeclaredIsStateful: false)), _commandLineOptions.Object); XElement[] testRunParameters = [.. runSettingsDocument.XPathSelectElements("RunSettings/TestRunParameters/Parameter")]; @@ -158,7 +186,7 @@ public void Patch_WhenRunSettingsDoesNotExist_AddParameters() }); _configuration.Setup(x => x[PlatformConfigurationConstants.PlatformResultDirectory]).Returns("/PlatformResultDirectory"); - XDocument runSettingsDocument = RunSettingsPatcher.Patch(null, _configuration.Object, new ClientInfoService(string.Empty, string.Empty, new ClientCapabilitiesService(IsStateful: false)), + XDocument runSettingsDocument = RunSettingsPatcher.Patch(null, _configuration.Object, new ClientInfoService(string.Empty, string.Empty, new ClientCapabilitiesService(DeclaredIsStateful: false)), _commandLineOptions.Object); XElement[] testRunParameters = [.. runSettingsDocument.XPathSelectElements("RunSettings/TestRunParameters/Parameter")]; diff --git a/test/UnitTests/Microsoft.Testing.Platform.ServerMode.Client.Sources.UnitTests/MtpServerClientTests.cs b/test/UnitTests/Microsoft.Testing.Platform.ServerMode.Client.Sources.UnitTests/MtpServerClientTests.cs index bcfd6ab387..e4684db274 100644 --- a/test/UnitTests/Microsoft.Testing.Platform.ServerMode.Client.Sources.UnitTests/MtpServerClientTests.cs +++ b/test/UnitTests/Microsoft.Testing.Platform.ServerMode.Client.Sources.UnitTests/MtpServerClientTests.cs @@ -51,6 +51,40 @@ public async Task InitializeAsync_DecodesServerCapabilities() Assert.IsTrue(initializeArgs.Capabilities.IsStateful); } + [TestMethod] + public async Task InitializeAsync_DefaultOptions_LeaveStatefulnessUndeclared() + { + using FakeMtpServer server = new(); + using MtpServerClient client = server.ConnectClient(); + + _ = await WithTimeoutAsync(client.InitializeAsync(TestContext.CancellationToken)).ConfigureAwait(false); + + InitializeRequestArgs initializeArgs = GetSingleRequestParams(server, JsonRpcMethods.Initialize); + Assert.IsNull(initializeArgs.Capabilities.IsStateful); + } + + [TestMethod] + public void SerializeClientCapabilities_UndeclaredStatefulness_OmitsProperty() + { + IDictionary serialized = SerializerUtilities.Serialize( + new ClientCapabilities(DebuggerProvider: false, IsStateful: null)); + var testingCapabilities = (IDictionary)serialized[JsonRpcStrings.Testing]!; + + Assert.IsFalse(testingCapabilities.ContainsKey(JsonRpcStrings.IsStateful)); + } + + [TestMethod] + [DataRow(true)] + [DataRow(false)] + public void SerializeClientCapabilities_DeclaredStatefulness_IncludesProperty(bool isStateful) + { + IDictionary serialized = SerializerUtilities.Serialize( + new ClientCapabilities(DebuggerProvider: false, IsStateful: isStateful)); + var testingCapabilities = (IDictionary)serialized[JsonRpcStrings.Testing]!; + + Assert.AreEqual(isStateful, testingCapabilities[JsonRpcStrings.IsStateful]); + } + [TestMethod] public async Task InitializeAsync_LegacyServerWithoutProtocolVersion_Succeeds() { diff --git a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/ServerMode/FormatterUtilitiesTests.cs b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/ServerMode/FormatterUtilitiesTests.cs index 507c5a61bf..881a4a4318 100644 --- a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/ServerMode/FormatterUtilitiesTests.cs +++ b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/ServerMode/FormatterUtilitiesTests.cs @@ -559,6 +559,69 @@ public async Task Serialize_OneSidedAssertionFailureProperty_DoesNotSpliceInExce Assert.DoesNotContain("legacy", serialized); } + [TestMethod] + public async Task Serialize_ExceptionStates_IncludeInnerExceptionMessagesAndStackTraces() + { + var innerException = new FixedStackTraceException("inner", "inner-stack"); + var exception = new FixedStackTraceException("outer", "outer-stack", innerException); + string expectedMessage = $"explanation{Environment.NewLine} ---> {typeof(FixedStackTraceException).FullName}: inner"; + string expectedStackTrace = $"outer-stack{Environment.NewLine}--- Inner exception stack trace ({typeof(FixedStackTraceException).FullName}) ---{Environment.NewLine}inner-stack"; + +#pragma warning disable CS0618, MTP0001 // Type or member is obsolete + (TestNodeStateProperty State, string ExecutionState)[] states = + [ + (new FailedTestNodeStateProperty(exception, "explanation"), "failed"), + (new ErrorTestNodeStateProperty(exception, "explanation"), "error"), + (new TimeoutTestNodeStateProperty(exception, "explanation"), "timed-out"), + (new CancelledTestNodeStateProperty(exception, "explanation"), "canceled"), + ]; +#pragma warning restore CS0618, MTP0001 // Type or member is obsolete + + foreach ((TestNodeStateProperty state, string executionState) in states) + { + var testNode = new TestNode + { + Uid = $"test-{executionState}", + DisplayName = $"Test {executionState}", + Properties = new PropertyBag(state), + }; + + IDictionary properties = SerializerUtilities.Serialize(testNode); + string serialized = await _formatter.SerializeAsync(testNode); + + Assert.AreEqual(expectedMessage, properties["error.message"]); + Assert.AreEqual(expectedStackTrace, properties["error.stacktrace"]); + Assert.Contains(EscapeJsonString(expectedMessage), serialized); + Assert.Contains(EscapeJsonString(expectedStackTrace), serialized); + } + } + + [TestMethod] + public void FormatException_AggregateException_IncludesEveryBranch() + { + var firstException = new FixedStackTraceException("first", "first-stack"); + var secondException = new FixedStackTraceException("second", "second-stack"); + var aggregateException = new AggregateException("aggregate", firstException, secondException); + + (string? message, string? stackTrace) = SerializerUtilities.FormatException(null, aggregateException); + + Assert.AreEqual( + string.Join( + Environment.NewLine, + aggregateException.Message, + $" ---> {typeof(FixedStackTraceException).FullName}: first", + $" ---> {typeof(FixedStackTraceException).FullName}: second"), + message); + Assert.AreEqual( + string.Join( + Environment.NewLine, + $"--- Inner exception stack trace ({typeof(FixedStackTraceException).FullName}) ---", + "first-stack", + $"--- Inner exception stack trace ({typeof(FixedStackTraceException).FullName}) ---", + "second-stack"), + stackTrace); + } + [DataRow(typeof(DiscoverRequestArgs))] [DataRow(typeof(RunRequestArgs))] [TestMethod] @@ -1049,4 +1112,32 @@ private object Deserialize(Type type, string instanceSerialized) _ when type == typeof(ServerCapabilities) => Deserialize(instanceSerialized)!, _ => throw new NotImplementedException($"Deserializer for type not implemented '{type}'"), }; + + private static string EscapeJsonString(string value) + { + string escapedValue = value + .Replace("\\", "\\\\") + .Replace("\"", "\\\"") + .Replace("\r", "\\r") + .Replace("\n", "\\n"); + +#if NETCOREAPP + escapedValue = escapedValue + .Replace("+", "\\u002B") + .Replace(">", "\\u003E"); +#endif + + return escapedValue; + } + + private sealed class FixedStackTraceException : Exception + { + private readonly string _stackTrace; + + public FixedStackTraceException(string message, string stackTrace, Exception? innerException = null) + : base(message, innerException) + => _stackTrace = stackTrace; + + public override string StackTrace => _stackTrace; + } } diff --git a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/ServerMode/JsonTests.cs b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/ServerMode/JsonTests.cs index 4d2a63cd2b..c500c66d5f 100644 --- a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/ServerMode/JsonTests.cs +++ b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/ServerMode/JsonTests.cs @@ -223,7 +223,7 @@ public void Deserialize_InitializeRequest_WithIsStatefulTrue_StjPath_SurfacesSta } [TestMethod] - public void Deserialize_InitializeRequest_WithoutIsStateful_StjPath_DefaultsToStateless() + public void Deserialize_InitializeRequest_WithIsStatefulFalse_StjPath_SurfacesStatelessClient() { // Arrange Json json = new(); @@ -231,7 +231,7 @@ public void Deserialize_InitializeRequest_WithoutIsStateful_StjPath_DefaultsToSt { "processId": 1, "clientInfo": { "name": "client", "version": "1.0.0" }, - "capabilities": { "testing": { "debuggerProvider": true } } + "capabilities": { "testing": { "debuggerProvider": true, "isStateful": false } } } """; @@ -242,6 +242,68 @@ public void Deserialize_InitializeRequest_WithoutIsStateful_StjPath_DefaultsToSt Assert.IsFalse(args.Capabilities.IsStateful); } + [TestMethod] + public void Deserialize_InitializeRequest_WithoutIsStateful_StjPath_LeavesCapabilityUndeclared() + { + // Arrange + Json json = new(); + const string initializeParams = """ + { + "processId": 1, + "clientInfo": { "name": "client", "version": "1.0.0" }, + "capabilities": { "testing": { "debuggerProvider": true } } + } + """; + + // Act + InitializeRequestArgs args = json.Deserialize(Encoding.UTF8.GetBytes(initializeParams).AsMemory()); + + // Assert + Assert.IsNull(args.Capabilities.IsStateful); + } + + [TestMethod] + public void Deserialize_InitializeRequest_WithInvalidIsStateful_StjPath_Throws() + { + // Arrange + Json json = new(); + const string initializeParams = """ + { + "processId": 1, + "clientInfo": { "name": "client", "version": "1.0.0" }, + "capabilities": { "testing": { "debuggerProvider": true, "isStateful": "false" } } + } + """; + + // Act + MessageFormatException exception = Assert.ThrowsExactly( + () => json.Deserialize(Encoding.UTF8.GetBytes(initializeParams).AsMemory())); + + // Assert + Assert.Contains(JsonRpcStrings.IsStateful, exception.Message); + } + + [TestMethod] + public void Deserialize_InitializeRequest_WithNullIsStateful_StjPath_Throws() + { + // Arrange + Json json = new(); + const string initializeParams = """ + { + "processId": 1, + "clientInfo": { "name": "client", "version": "1.0.0" }, + "capabilities": { "testing": { "debuggerProvider": true, "isStateful": null } } + } + """; + + // Act + MessageFormatException exception = Assert.ThrowsExactly( + () => json.Deserialize(Encoding.UTF8.GetBytes(initializeParams).AsMemory())); + + // Assert + Assert.Contains(JsonRpcStrings.IsStateful, exception.Message); + } + [TestMethod] public void Deserialize_ClientCapabilities_WithIsStatefulTrue_JsonitePath_SurfacesStatefulClient() { @@ -266,7 +328,7 @@ public void Deserialize_ClientCapabilities_WithIsStatefulTrue_JsonitePath_Surfac } [TestMethod] - public void Deserialize_ClientCapabilities_WithoutIsStateful_JsonitePath_DefaultsToStateless() + public void Deserialize_ClientCapabilities_WithIsStatefulFalse_JsonitePath_SurfacesStatelessClient() { // Arrange Dictionary properties = new() @@ -276,6 +338,7 @@ public void Deserialize_ClientCapabilities_WithoutIsStateful_JsonitePath_Default ["testing"] = new Dictionary { ["debuggerProvider"] = true, + ["isStateful"] = false, }, }, }; @@ -287,6 +350,76 @@ public void Deserialize_ClientCapabilities_WithoutIsStateful_JsonitePath_Default Assert.IsFalse(capabilities.IsStateful); } + [TestMethod] + public void Deserialize_ClientCapabilities_WithoutIsStateful_JsonitePath_LeavesCapabilityUndeclared() + { + // Arrange + Dictionary properties = new() + { + ["capabilities"] = new Dictionary + { + ["testing"] = new Dictionary + { + ["debuggerProvider"] = true, + }, + }, + }; + + // Act + ClientCapabilities capabilities = SerializerUtilities.Deserialize(properties); + + // Assert + Assert.IsNull(capabilities.IsStateful); + } + + [TestMethod] + public void Deserialize_ClientCapabilities_WithInvalidIsStateful_JsonitePath_Throws() + { + // Arrange + Dictionary properties = new() + { + ["capabilities"] = new Dictionary + { + ["testing"] = new Dictionary + { + ["debuggerProvider"] = true, + ["isStateful"] = "false", + }, + }, + }; + + // Act + MessageFormatException exception = Assert.ThrowsExactly( + () => SerializerUtilities.Deserialize(properties)); + + // Assert + Assert.Contains(JsonRpcStrings.IsStateful, exception.Message); + } + + [TestMethod] + public void Deserialize_ClientCapabilities_WithNullIsStateful_JsonitePath_Throws() + { + // Arrange + Dictionary properties = new() + { + ["capabilities"] = new Dictionary + { + ["testing"] = new Dictionary + { + ["debuggerProvider"] = true, + ["isStateful"] = null, + }, + }, + }; + + // Act + MessageFormatException exception = Assert.ThrowsExactly( + () => SerializerUtilities.Deserialize(properties)); + + // Assert + Assert.Contains(JsonRpcStrings.IsStateful, exception.Message); + } + [TestMethod] public void Deserialize_UntypedDictionary_WithNonInt32Numbers_StjPath_PreservesNumericType() { diff --git a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Services/ClientCapabilitiesExtensionsTests.cs b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Services/ClientCapabilitiesExtensionsTests.cs new file mode 100644 index 0000000000..851e47b168 --- /dev/null +++ b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Services/ClientCapabilitiesExtensionsTests.cs @@ -0,0 +1,35 @@ +// 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.Services; + +namespace Microsoft.Testing.Platform.UnitTests; + +#pragma warning disable TPEXP // IClientCapabilities and ClientCapabilitiesExtensions are experimental. +[TestClass] +public sealed class ClientCapabilitiesExtensionsTests +{ + [TestMethod] + public void IsStateful_UndeclaredCapability_DefaultsToFalse() + { + IClientCapabilities capabilities = new ClientCapabilitiesService(DeclaredIsStateful: null); + + Assert.IsFalse(capabilities.IsStateful); + } + + [TestMethod] + [DataRow(true)] + [DataRow(false)] + public void GetIsStateful_ForwardsCustomImplementation(bool isStateful) + { + IClientCapabilities capabilities = new CustomClientCapabilities(isStateful); + + Assert.AreEqual(isStateful, capabilities.GetIsStateful()); + } + + private sealed class CustomClientCapabilities(bool isStateful) : IClientCapabilities + { + public bool IsStateful { get; } = isStateful; + } +} +#pragma warning restore TPEXP