diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsReporter.cs b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsReporter.cs index ddd2e6d540..c27425b91e 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsReporter.cs +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsReporter.cs @@ -258,7 +258,7 @@ private async Task WriteExceptionAsync(string testDisplayName, string testName, logger.LogTrace($"Using relative path '{relativePath}'."); } } - else if (file.StartsWith(repoRoot, StringComparison.CurrentCultureIgnoreCase)) + else if (file.StartsWith(repoRoot, StringComparison.OrdinalIgnoreCase)) { if (logger.IsEnabled(LogLevel.Trace)) { diff --git a/src/TestFramework/TestFramework/Assertions/Assert.AreEqual.InterpolatedStringHandlers.cs b/src/TestFramework/TestFramework/Assertions/Assert.AreEqual.InterpolatedStringHandlers.cs index 888d647cc8..fabe9b7005 100644 --- a/src/TestFramework/TestFramework/Assertions/Assert.AreEqual.InterpolatedStringHandlers.cs +++ b/src/TestFramework/TestFramework/Assertions/Assert.AreEqual.InterpolatedStringHandlers.cs @@ -12,6 +12,7 @@ namespace Microsoft.VisualStudio.TestTools.UnitTesting; /// public sealed partial class Assert { + [StackTraceHidden] [InterpolatedStringHandler] [EditorBrowsable(EditorBrowsableState.Never)] #pragma warning disable CS1591 // Missing XML comment for publicly visible type or member @@ -86,6 +87,7 @@ internal void ComputeAssertion(string expectedExpression, string actualExpressio #pragma warning restore RS0027 // API with optional parameter(s) should have the most parameters amongst its public overloads } + [StackTraceHidden] [InterpolatedStringHandler] [EditorBrowsable(EditorBrowsableState.Never)] public readonly struct AssertAreNotEqualInterpolatedStringHandler @@ -152,6 +154,7 @@ internal void ComputeAssertion(string notExpectedExpression, string actualExpres #pragma warning restore RS0027 // API with optional parameter(s) should have the most parameters amongst its public overloads } + [StackTraceHidden] [InterpolatedStringHandler] [EditorBrowsable(EditorBrowsableState.Never)] public readonly struct AssertNonGenericAreEqualInterpolatedStringHandler @@ -263,6 +266,7 @@ internal void ComputeAssertion(string expectedExpression, string actualExpressio #pragma warning restore RS0027 // API with optional parameter(s) should have the most parameters amongst its public overloads } + [StackTraceHidden] [InterpolatedStringHandler] [EditorBrowsable(EditorBrowsableState.Never)] public readonly struct AssertNonGenericAreNotEqualInterpolatedStringHandler diff --git a/src/TestFramework/TestFramework/Assertions/Assert.AreSame.cs b/src/TestFramework/TestFramework/Assertions/Assert.AreSame.cs index 2a3eaf72dc..189a317a02 100644 --- a/src/TestFramework/TestFramework/Assertions/Assert.AreSame.cs +++ b/src/TestFramework/TestFramework/Assertions/Assert.AreSame.cs @@ -14,6 +14,7 @@ namespace Microsoft.VisualStudio.TestTools.UnitTesting; /// public sealed partial class Assert { + [StackTraceHidden] [InterpolatedStringHandler] [EditorBrowsable(EditorBrowsableState.Never)] #pragma warning disable CS1591 // Missing XML comment for publicly visible type or member @@ -74,6 +75,7 @@ internal void ComputeAssertion(string expectedExpression, string actualExpressio #pragma warning restore RS0027 // API with optional parameter(s) should have the most parameters amongst its public overloads } + [StackTraceHidden] [InterpolatedStringHandler] [EditorBrowsable(EditorBrowsableState.Never)] public readonly struct AssertAreNotSameInterpolatedStringHandler diff --git a/src/TestFramework/TestFramework/Assertions/Assert.Contains.cs b/src/TestFramework/TestFramework/Assertions/Assert.Contains.cs index eb078f064f..ad6477c4e9 100644 --- a/src/TestFramework/TestFramework/Assertions/Assert.Contains.cs +++ b/src/TestFramework/TestFramework/Assertions/Assert.Contains.cs @@ -12,6 +12,7 @@ namespace Microsoft.VisualStudio.TestTools.UnitTesting; /// public sealed partial class Assert { + [StackTraceHidden] [InterpolatedStringHandler] [EditorBrowsable(EditorBrowsableState.Never)] #pragma warning disable CS1591 // Missing XML comment for publicly visible type or member diff --git a/src/TestFramework/TestFramework/Assertions/Assert.Count.cs b/src/TestFramework/TestFramework/Assertions/Assert.Count.cs index 470f8d6383..560d7c41a7 100644 --- a/src/TestFramework/TestFramework/Assertions/Assert.Count.cs +++ b/src/TestFramework/TestFramework/Assertions/Assert.Count.cs @@ -12,6 +12,7 @@ namespace Microsoft.VisualStudio.TestTools.UnitTesting; /// public sealed partial class Assert { + [StackTraceHidden] [InterpolatedStringHandler] [EditorBrowsable(EditorBrowsableState.Never)] #pragma warning disable CS1591 // Missing XML comment for publicly visible type or member @@ -95,6 +96,7 @@ public void AppendFormatted(object? value, int alignment = 0, string? format = n #pragma warning restore RS0027 // API with optional parameter(s) should have the most parameters amongst its public overloads } + [StackTraceHidden] [InterpolatedStringHandler] [EditorBrowsable(EditorBrowsableState.Never)] public readonly struct AssertIsNotEmptyInterpolatedStringHandler diff --git a/src/TestFramework/TestFramework/Assertions/Assert.IsExactInstanceOfType.cs b/src/TestFramework/TestFramework/Assertions/Assert.IsExactInstanceOfType.cs index 5ac767cca9..f1d00d046f 100644 --- a/src/TestFramework/TestFramework/Assertions/Assert.IsExactInstanceOfType.cs +++ b/src/TestFramework/TestFramework/Assertions/Assert.IsExactInstanceOfType.cs @@ -14,6 +14,7 @@ namespace Microsoft.VisualStudio.TestTools.UnitTesting; /// public sealed partial class Assert { + [StackTraceHidden] [InterpolatedStringHandler] [EditorBrowsable(EditorBrowsableState.Never)] #pragma warning disable CS1591 // Missing XML comment for publicly visible type or member @@ -76,6 +77,7 @@ internal void ComputeAssertion(string valueExpression) #pragma warning restore RS0027 // API with optional parameter(s) should have the most parameters amongst its public overloads } + [StackTraceHidden] [InterpolatedStringHandler] [EditorBrowsable(EditorBrowsableState.Never)] public readonly struct AssertGenericIsExactInstanceOfTypeInterpolatedStringHandler @@ -135,6 +137,7 @@ internal void ComputeAssertion(string valueExpression) #pragma warning restore RS0027 // API with optional parameter(s) should have the most parameters amongst its public overloads } + [StackTraceHidden] [InterpolatedStringHandler] [EditorBrowsable(EditorBrowsableState.Never)] public readonly struct AssertIsNotExactInstanceOfTypeInterpolatedStringHandler @@ -196,6 +199,7 @@ internal void ComputeAssertion(string valueExpression) #pragma warning restore RS0027 // API with optional parameter(s) should have the most parameters amongst its public overloads } + [StackTraceHidden] [InterpolatedStringHandler] [EditorBrowsable(EditorBrowsableState.Never)] public readonly struct AssertGenericIsNotExactInstanceOfTypeInterpolatedStringHandler diff --git a/src/TestFramework/TestFramework/Assertions/Assert.IsInstanceOfType.cs b/src/TestFramework/TestFramework/Assertions/Assert.IsInstanceOfType.cs index 872c70da1f..3c6897e0be 100644 --- a/src/TestFramework/TestFramework/Assertions/Assert.IsInstanceOfType.cs +++ b/src/TestFramework/TestFramework/Assertions/Assert.IsInstanceOfType.cs @@ -14,6 +14,7 @@ namespace Microsoft.VisualStudio.TestTools.UnitTesting; /// public sealed partial class Assert { + [StackTraceHidden] [InterpolatedStringHandler] [EditorBrowsable(EditorBrowsableState.Never)] #pragma warning disable CS1591 // Missing XML comment for publicly visible type or member @@ -76,6 +77,7 @@ internal void ComputeAssertion(string valueExpression) #pragma warning restore RS0027 // API with optional parameter(s) should have the most parameters amongst its public overloads } + [StackTraceHidden] [InterpolatedStringHandler] [EditorBrowsable(EditorBrowsableState.Never)] public readonly struct AssertGenericIsInstanceOfTypeInterpolatedStringHandler @@ -135,6 +137,7 @@ internal void ComputeAssertion(string valueExpression) #pragma warning restore RS0027 // API with optional parameter(s) should have the most parameters amongst its public overloads } + [StackTraceHidden] [InterpolatedStringHandler] [EditorBrowsable(EditorBrowsableState.Never)] public readonly struct AssertIsNotInstanceOfTypeInterpolatedStringHandler @@ -196,6 +199,7 @@ internal void ComputeAssertion(string valueExpression) #pragma warning restore RS0027 // API with optional parameter(s) should have the most parameters amongst its public overloads } + [StackTraceHidden] [InterpolatedStringHandler] [EditorBrowsable(EditorBrowsableState.Never)] public readonly struct AssertGenericIsNotInstanceOfTypeInterpolatedStringHandler diff --git a/src/TestFramework/TestFramework/Assertions/Assert.IsNull.cs b/src/TestFramework/TestFramework/Assertions/Assert.IsNull.cs index 033347aa30..4a7a34073d 100644 --- a/src/TestFramework/TestFramework/Assertions/Assert.IsNull.cs +++ b/src/TestFramework/TestFramework/Assertions/Assert.IsNull.cs @@ -16,6 +16,7 @@ namespace Microsoft.VisualStudio.TestTools.UnitTesting; public sealed partial class Assert { + [StackTraceHidden] [InterpolatedStringHandler] [EditorBrowsable(EditorBrowsableState.Never)] #pragma warning disable CS1591 // Missing XML comment for publicly visible type or member @@ -71,6 +72,7 @@ internal void ComputeAssertion(string valueExpression) } #pragma warning restore CS1591 // Missing XML comment for publicly visible type or member + [StackTraceHidden] [InterpolatedStringHandler] [EditorBrowsable(EditorBrowsableState.Never)] #pragma warning disable CS1591 // Missing XML comment for publicly visible type or member diff --git a/src/TestFramework/TestFramework/Assertions/Assert.IsTrue.cs b/src/TestFramework/TestFramework/Assertions/Assert.IsTrue.cs index 88647fff5a..56e10d6dea 100644 --- a/src/TestFramework/TestFramework/Assertions/Assert.IsTrue.cs +++ b/src/TestFramework/TestFramework/Assertions/Assert.IsTrue.cs @@ -16,6 +16,7 @@ namespace Microsoft.VisualStudio.TestTools.UnitTesting; public sealed partial class Assert { + [StackTraceHidden] [InterpolatedStringHandler] [EditorBrowsable(EditorBrowsableState.Never)] #pragma warning disable CS1591 // Missing XML comment for publicly visible type or member @@ -70,6 +71,7 @@ internal void ComputeAssertion(string conditionExpression) public void AppendFormatted(object? value, int alignment = 0, string? format = null) => _builder!.AppendFormat(null, $"{{0,{alignment}:{format}}}", value); } + [StackTraceHidden] [InterpolatedStringHandler] [EditorBrowsable(EditorBrowsableState.Never)] public readonly struct AssertIsFalseInterpolatedStringHandler diff --git a/src/TestFramework/TestFramework/Assertions/Assert.That.cs b/src/TestFramework/TestFramework/Assertions/Assert.That.cs index 61589839b6..b5e3b703da 100644 --- a/src/TestFramework/TestFramework/Assertions/Assert.That.cs +++ b/src/TestFramework/TestFramework/Assertions/Assert.That.cs @@ -8,6 +8,7 @@ namespace Microsoft.VisualStudio.TestTools.UnitTesting; /// /// Provides That extension to Assert class. /// +[StackTraceHidden] public static partial class AssertExtensions { // Constants for standardized display values diff --git a/src/TestFramework/TestFramework/Assertions/Assert.ThrowsException.cs b/src/TestFramework/TestFramework/Assertions/Assert.ThrowsException.cs index c82a078cad..b2b2c7df98 100644 --- a/src/TestFramework/TestFramework/Assertions/Assert.ThrowsException.cs +++ b/src/TestFramework/TestFramework/Assertions/Assert.ThrowsException.cs @@ -14,6 +14,7 @@ namespace Microsoft.VisualStudio.TestTools.UnitTesting; /// public sealed partial class Assert { + [StackTraceHidden] [InterpolatedStringHandler] [EditorBrowsable(EditorBrowsableState.Never)] #pragma warning disable CS1591 // Missing XML comment for publicly visible type or member @@ -85,6 +86,7 @@ internal TException ComputeAssertion(string actionExpression) #pragma warning restore RS0027 // API with optional parameter(s) should have the most parameters amongst its public overloads } + [StackTraceHidden] [InterpolatedStringHandler] [EditorBrowsable(EditorBrowsableState.Never)] public readonly struct AssertThrowsExactlyInterpolatedStringHandler diff --git a/src/TestFramework/TestFramework/Assertions/Assert.cs b/src/TestFramework/TestFramework/Assertions/Assert.cs index 2f4874cbe0..b345210c39 100644 --- a/src/TestFramework/TestFramework/Assertions/Assert.cs +++ b/src/TestFramework/TestFramework/Assertions/Assert.cs @@ -8,6 +8,7 @@ namespace Microsoft.VisualStudio.TestTools.UnitTesting; /// unit tests. If the condition being tested is not met, an exception /// is thrown. /// +[StackTraceHidden] public sealed partial class Assert { private Assert() diff --git a/src/TestFramework/TestFramework/Assertions/AssertScope.cs b/src/TestFramework/TestFramework/Assertions/AssertScope.cs index 0d0b678ea0..e12f060cbb 100644 --- a/src/TestFramework/TestFramework/Assertions/AssertScope.cs +++ b/src/TestFramework/TestFramework/Assertions/AssertScope.cs @@ -9,6 +9,7 @@ namespace Microsoft.VisualStudio.TestTools.UnitTesting; /// Represents a scope in which assertion failures are collected instead of thrown immediately. /// When the scope is disposed, all collected failures are thrown as a single . /// +[StackTraceHidden] internal sealed class AssertScope : IDisposable { private static readonly AsyncLocal CurrentScope = new(); diff --git a/src/TestFramework/TestFramework/Assertions/AssertionValueRenderer.cs b/src/TestFramework/TestFramework/Assertions/AssertionValueRenderer.cs index fd34336624..b49b85823e 100644 --- a/src/TestFramework/TestFramework/Assertions/AssertionValueRenderer.cs +++ b/src/TestFramework/TestFramework/Assertions/AssertionValueRenderer.cs @@ -6,6 +6,7 @@ namespace Microsoft.VisualStudio.TestTools.UnitTesting; /// /// Renders values for display in structured assertion messages following the RFC 012 value rendering rules. /// +[StackTraceHidden] internal static class AssertionValueRenderer { /// diff --git a/src/TestFramework/TestFramework/Assertions/CollectionAssert.cs b/src/TestFramework/TestFramework/Assertions/CollectionAssert.cs index 56dd75d81c..30a2558bb4 100644 --- a/src/TestFramework/TestFramework/Assertions/CollectionAssert.cs +++ b/src/TestFramework/TestFramework/Assertions/CollectionAssert.cs @@ -8,6 +8,7 @@ namespace Microsoft.VisualStudio.TestTools.UnitTesting; /// with collections within unit tests. If the condition being tested is not /// met, an exception is thrown. /// +[StackTraceHidden] public sealed partial class CollectionAssert { #region Singleton constructor diff --git a/src/TestFramework/TestFramework/Assertions/EvidenceBlock.cs b/src/TestFramework/TestFramework/Assertions/EvidenceBlock.cs index 25e9bfac22..8094e17bd6 100644 --- a/src/TestFramework/TestFramework/Assertions/EvidenceBlock.cs +++ b/src/TestFramework/TestFramework/Assertions/EvidenceBlock.cs @@ -7,6 +7,7 @@ namespace Microsoft.VisualStudio.TestTools.UnitTesting; /// Represents the evidence block of a structured assertion message, containing labeled value lines /// such as expected/actual values and assertion-specific details. /// +[StackTraceHidden] internal sealed class EvidenceBlock { private readonly List _lines = []; diff --git a/src/TestFramework/TestFramework/Assertions/EvidenceLine.cs b/src/TestFramework/TestFramework/Assertions/EvidenceLine.cs index bb09f5f150..420482e77f 100644 --- a/src/TestFramework/TestFramework/Assertions/EvidenceLine.cs +++ b/src/TestFramework/TestFramework/Assertions/EvidenceLine.cs @@ -6,4 +6,5 @@ namespace Microsoft.VisualStudio.TestTools.UnitTesting; /// /// Represents a single labeled line in the evidence block of a structured assertion message. /// +[StackTraceHidden] internal readonly record struct EvidenceLine(string Label, string Value); diff --git a/src/TestFramework/TestFramework/Assertions/StringAssert.cs b/src/TestFramework/TestFramework/Assertions/StringAssert.cs index f3bebb718f..08106d7e22 100644 --- a/src/TestFramework/TestFramework/Assertions/StringAssert.cs +++ b/src/TestFramework/TestFramework/Assertions/StringAssert.cs @@ -6,6 +6,7 @@ namespace Microsoft.VisualStudio.TestTools.UnitTesting; /// /// The string assert. /// +[StackTraceHidden] public sealed class StringAssert { #region Singleton constructor diff --git a/src/TestFramework/TestFramework/Assertions/StructuredAssertionMessage.cs b/src/TestFramework/TestFramework/Assertions/StructuredAssertionMessage.cs index 01b55290cf..523071de8d 100644 --- a/src/TestFramework/TestFramework/Assertions/StructuredAssertionMessage.cs +++ b/src/TestFramework/TestFramework/Assertions/StructuredAssertionMessage.cs @@ -14,6 +14,7 @@ namespace Microsoft.VisualStudio.TestTools.UnitTesting; /// <call-site expression> /// /// +[StackTraceHidden] internal sealed class StructuredAssertionMessage { private const string AssertionPrefix = "Assertion failed."; diff --git a/test/IntegrationTests/MSTest.Acceptance.IntegrationTests/SoftAssertionTests.cs b/test/IntegrationTests/MSTest.Acceptance.IntegrationTests/SoftAssertionTests.cs index 4227f684eb..aa247c3f30 100644 --- a/test/IntegrationTests/MSTest.Acceptance.IntegrationTests/SoftAssertionTests.cs +++ b/test/IntegrationTests/MSTest.Acceptance.IntegrationTests/SoftAssertionTests.cs @@ -41,7 +41,7 @@ public async Task ScopeWithMultipleFailures_TestFailsWithAggregatedMessage() // Validate the output includes the aggregate message and that inner exception stack traces // point to the test method (assertion call site). testHostResult.AssertOutputMatchesRegex( - """failed ScopeWithMultipleFailures \(\d+ms\)[\s\S]+2 assertion\(s\) failed within the assert scope\.[\s\S]+at Microsoft\.VisualStudio\.TestTools\.UnitTesting\.AssertScope\.Dispose\(\)[\s\S]+at UnitTest1\.ScopeWithMultipleFailures\(\)"""); + """failed ScopeWithMultipleFailures \(\d+ms\)[\s\S]+2 assertion\(s\) failed within the assert scope\.[\s\S]+at UnitTest1\.ScopeWithMultipleFailures\(\)"""); } [TestMethod] diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/AzureDevOpsTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/AzureDevOpsTests.cs index 7df65b6273..3e52e9d969 100644 --- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/AzureDevOpsTests.cs +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/AzureDevOpsTests.cs @@ -198,6 +198,81 @@ public void DoesNotSkipUserFrameWhoseTypeNameStartsWithAssert() $"\nLogs:\n{string.Join("\n", logger.Logs)}"); } + [TestMethod] + public void RealAssertFailure_AnnotationPointsAtTestMethodAndNotAtFrameworkFile() + { + // End-to-end acceptance for https://github.com/microsoft/testfx/issues/8277. + // + // Trigger a *real* MSTest assertion failure (no synthetic stack trace) and ensure the + // AzDO reporter annotates the test method's source file rather than the framework + // partial-class file that actually holds the failing implementation. + // + // This regression is double-guarded: + // * On .NET 6+: every public Assert/CollectionAssert/StringAssert method carries + // [StackTraceHidden], so the framework frame should not even appear in the + // captured stack trace. + // * On older runtimes (.NET Framework / netstandard2.0 consumers): the attribute is + // a no-op, but the reporter's type-prefix heuristic still skips the framework + // frame. + // Either way, the annotation must point at this test method's source file. + (string testFile, _) = GetCurrentLocation(); + + AssertFailedException error; + try + { +#pragma warning disable MSTEST0025 // Use 'Assert.Fail' instead of an always-failing assert - we deliberately want a real Assert.AreEqual failure so the captured stack trace reflects what users see. + Assert.AreEqual(1, 2); +#pragma warning restore MSTEST0025 + throw new InvalidOperationException("Expected AssertFailedException was not thrown."); + } + catch (AssertFailedException ex) + { + error = ex; + } + + var logger = new TextLogger(); + // Trim ##. If we keep it, then when the test fails, the assertion failure will get printed to screen and picked up incorrectly by AzDO, because it scans all output for the ##vso... pattern. + string? text = AzureDevOpsReporter.GetErrorText( + "MyTestDisplayName", + explanation: null, + error, + "severity", + new SystemFileSystem(), + logger, + "net9.0")?.TrimStart('#'); + + // Also neutralize any '##vso[' that the trace logger captured (the reporter logs the full + // command line at trace level) so the assertion failure messages below cannot smuggle a + // real AzDO command into the build output when this test fails on CI. + string safeLogs = string.Join("\n", logger.Logs).Replace("##vso[", "vso["); + + Assert.IsNotNull(text, $"AzDO reporter should have produced an annotation. Logs:\n{safeLogs}"); + + // Mirror the path normalization performed by AzureDevOpsReporter.GetErrorText: + // * Strip the deterministic-build root '/_/' (set by true + // in CI), then fall back to stripping the runtime repo root for local builds. + // * Normalize separators to '/'. + // Without this, the test breaks in CI where [CallerFilePath] returns '/_/test/...' + // but the reporter strips '/_/' and emits 'sourcepath=test/...'. + const string DeterministicBuildRoot = "/_/"; + string normalizedTestFile = testFile.Replace('\\', '/'); + string repoRoot = RootFinder.Find().Replace('\\', '/'); + string expectedRelativeFile = normalizedTestFile.StartsWith(DeterministicBuildRoot, StringComparison.OrdinalIgnoreCase) + ? normalizedTestFile.Substring(DeterministicBuildRoot.Length) + : normalizedTestFile.StartsWith(repoRoot, StringComparison.OrdinalIgnoreCase) + ? normalizedTestFile.Substring(repoRoot.Length).TrimStart('/') + : normalizedTestFile; + + Assert.Contains( + $"sourcepath={expectedRelativeFile};", + text, + $"AzDO annotation must point at the test method's source file. Got: {text}\nLogs:\n{safeLogs}"); + Assert.DoesNotContain( + "/src/TestFramework/TestFramework/Assertions/", + text, + $"AzDO annotation must not point at a framework Assert partial-class file. Got: {text}\nLogs:\n{safeLogs}"); + } + [TestMethod] public void FormatErrorMessage_PlacesTestNameAsTitleOnFirstLine() { diff --git a/test/UnitTests/TestFramework.UnitTests/Assertions/AssertStackTraceHiddenTests.cs b/test/UnitTests/TestFramework.UnitTests/Assertions/AssertStackTraceHiddenTests.cs new file mode 100644 index 0000000000..3484313f21 --- /dev/null +++ b/test/UnitTests/TestFramework.UnitTests/Assertions/AssertStackTraceHiddenTests.cs @@ -0,0 +1,187 @@ +// 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.Linq; +using System.Reflection; + +using AwesomeAssertions; + +using TestFramework.ForTestingMSTest; + +namespace Microsoft.VisualStudio.TestPlatform.TestFramework.UnitTests; + +/// +/// Guarantees that every public assertion API is covered by [StackTraceHidden], so +/// MSTest framework frames are stripped from on +/// runtimes that honor the attribute (.NET 6+). The reflection-based checks ensure new +/// public APIs added to // +/// in the future cannot silently regress this guarantee, and two runtime smoke tests +/// confirm the attribute is actually honored end-to-end. +/// +public class AssertStackTraceHiddenTests : TestContainer +{ + // We compare attributes by full type name rather than by Type identity because each + // assembly that needs the polyfill (MSTest.TestFramework, the test assembly itself, …) + // contributes its own internal copy of System.Diagnostics.StackTraceHiddenAttribute on + // pre-.NET 6 TFMs. Their FullName matches; their Type identities do not. + private const string StackTraceHiddenAttributeFullName = "System.Diagnostics.StackTraceHiddenAttribute"; + + public void Assert_AllPublicMethodsAreCoveredByStackTraceHidden() + => AssertAllPublicMethodsAreCovered(typeof(Assert)); + + public void CollectionAssert_AllPublicMethodsAreCoveredByStackTraceHidden() + => AssertAllPublicMethodsAreCovered(typeof(CollectionAssert)); + + public void StringAssert_AllPublicMethodsAreCoveredByStackTraceHidden() + => AssertAllPublicMethodsAreCovered(typeof(StringAssert)); + + public void AssertExtensions_AllPublicMethodsAreCoveredByStackTraceHidden() + { + Type extensionsType = typeof(Assert).Assembly.GetType("Microsoft.VisualStudio.TestTools.UnitTesting.AssertExtensions", throwOnError: true)!; + AssertAllPublicMethodsAreCovered(extensionsType); + } + + public void Assert_NestedInterpolatedStringHandlersAreCoveredByStackTraceHidden() + => AssertNestedHandlerTypesAreCovered(typeof(Assert)); + + public void InternalAssertionHelpers_AreCoveredByStackTraceHidden() + { + // These helpers sit between Assert/CollectionAssert/StringAssert and the throw site; + // they need to be hidden so the "hidden span" of frames is contiguous (see #8277). + string[] internalHelperNames = + [ + "Microsoft.VisualStudio.TestTools.UnitTesting.AssertScope", + "Microsoft.VisualStudio.TestTools.UnitTesting.StructuredAssertionMessage", + "Microsoft.VisualStudio.TestTools.UnitTesting.AssertionValueRenderer", + "Microsoft.VisualStudio.TestTools.UnitTesting.EvidenceBlock", + "Microsoft.VisualStudio.TestTools.UnitTesting.EvidenceLine", + ]; + + Assembly assembly = typeof(Assert).Assembly; + foreach (string typeName in internalHelperNames) + { + Type type = assembly.GetType(typeName, throwOnError: true)!; + HasStackTraceHiddenAttribute(type) + .Should() + .BeTrue($"internal helper '{typeName}' should carry [StackTraceHidden] so framework frames between Assert.* and the throw site are contiguously hidden"); + } + } + + public void AssertFailure_StackTraceDoesNotContainAnyFrameworkFrameOnNet6Plus() + { + // Behavioral smoke test: forces a real assertion failure and confirms the runtime + // actually strips MSTest frames from the captured stack trace. Reflection-only + // checks can't catch issues like a broken polyfill or the BCL ignoring our + // attribute, so we keep this one runtime check as a backstop. + AssertFailedException exception = CaptureFailure(() => Assert.AreEqual(1, 2)); + +#if NET6_0_OR_GREATER + exception.StackTrace.Should().NotBeNull(); + exception.StackTrace! + .Should() + .NotContain("Microsoft.VisualStudio.TestTools.UnitTesting.Assert.", because: $"[StackTraceHidden] should strip Assert frames on .NET 6+. Actual:{Environment.NewLine}{exception.StackTrace}"); +#else + exception.Should().NotBeNull(); +#endif + } + + public void AssertFailureInsideScope_StackTraceDoesNotContainAnyFrameworkFrameOnNet6Plus() + { + // Verifies the AssertScope rethrow path also produces a hidden stack on .NET 6+: + // the original failure is captured by ExceptionDispatchInfo and re-thrown from + // AssertScope.Dispose(), so AssertScope itself must also be hidden. + AssertFailedException exception = CaptureFailure(() => + { + using (Assert.Scope()) + { + Assert.AreEqual(1, 2); + } + }); + +#if NET6_0_OR_GREATER + exception.StackTrace.Should().NotBeNull(); + exception.StackTrace! + .Should() + .NotContain("Microsoft.VisualStudio.TestTools.UnitTesting.AssertScope.", because: $"AssertScope.Dispose() must also be hidden. Actual:{Environment.NewLine}{exception.StackTrace}"); + exception.StackTrace! + .Should() + .NotContain("Microsoft.VisualStudio.TestTools.UnitTesting.Assert.", because: $"the captured failure frame itself must remain hidden. Actual:{Environment.NewLine}{exception.StackTrace}"); +#else + exception.Should().NotBeNull(); +#endif + } + + private static void AssertAllPublicMethodsAreCovered(Type type) + { + bool typeIsHidden = HasStackTraceHiddenAttribute(type); + + MethodInfo[] publicMethods = type.GetMethods(BindingFlags.Public | BindingFlags.Static | BindingFlags.Instance | BindingFlags.DeclaredOnly); + + List uncovered = []; + foreach (MethodInfo method in publicMethods) + { + // Property accessors, operator overloads and compiler-emitted methods do not + // appear in user-visible stack traces; skip them to keep the contract focused on + // surface APIs. + if (method.IsSpecialName) + { + continue; + } + + // Equals/GetHashCode/ToString inherited via 'public static new' overloads on Assert + // are obsolete sentinels; they still go through Fail (hidden) so don't need + // independent annotation, but they are covered for free when the declaring type + // is hidden. Inherited Object methods (no override) aren't returned with + // DeclaredOnly, so nothing to skip here. + if (typeIsHidden || HasStackTraceHiddenAttribute(method)) + { + continue; + } + + uncovered.Add($"{method.DeclaringType!.FullName}.{method.Name}({string.Join(", ", method.GetParameters().Select(p => p.ParameterType.Name))})"); + } + + uncovered.Should().BeEmpty( + $"every public method on {type.FullName} must be covered by [StackTraceHidden] (either directly or via its declaring type) so MSTest frames are stripped from user stack traces on .NET 6+. Uncovered: {string.Join(", ", uncovered)}"); + } + + private static void AssertNestedHandlerTypesAreCovered(Type ownerType) + { + Type[] nestedTypes = ownerType.GetNestedTypes(BindingFlags.Public | BindingFlags.NonPublic); + Type[] handlers = nestedTypes + .Where(static t => t.Name.Contains("InterpolatedStringHandler", StringComparison.Ordinal)) + .ToArray(); + + // Sanity check: the assertion API exposes several InterpolatedStringHandler types; if + // this drops to zero we've likely broken our reflection assumptions rather than + // actually removed every handler. + handlers.Should().NotBeEmpty($"{ownerType.FullName} is expected to expose nested *InterpolatedStringHandler structs"); + + List uncovered = []; + foreach (Type handler in handlers.Where(static handler => !HasStackTraceHiddenAttribute(handler))) + { + uncovered.Add(handler.FullName ?? handler.Name); + } + + uncovered.Should().BeEmpty( + $"every nested *InterpolatedStringHandler under {ownerType.FullName} must carry [StackTraceHidden] (nested types have their own DeclaringType, so the outer type's attribute does not propagate). Uncovered: {string.Join(", ", uncovered)}"); + } + + private static bool HasStackTraceHiddenAttribute(MemberInfo member) + => member.GetCustomAttributesData() + .Any(static attr => attr.AttributeType.FullName == StackTraceHiddenAttributeFullName); + + private static AssertFailedException CaptureFailure(Action action) + { + try + { + action(); + } + catch (AssertFailedException ex) + { + return ex; + } + + throw new InvalidOperationException("Expected AssertFailedException was not thrown."); + } +}