diff --git a/TUnit.Analyzers/GlobalTestHooksAnalyzer.cs b/TUnit.Analyzers/GlobalTestHooksAnalyzer.cs index 285659f750..82060a783d 100644 --- a/TUnit.Analyzers/GlobalTestHooksAnalyzer.cs +++ b/TUnit.Analyzers/GlobalTestHooksAnalyzer.cs @@ -35,7 +35,7 @@ private void AnalyzeSymbol(SymbolAnalysisContext context) var attributes = methodSymbol.GetAttributes(); var globalHooks = attributes - .Where(x => IsGlobalHook(context, x, out _)) + .Where(x => IsGlobalHook(context, x, out _, out _)) .ToList(); if (!globalHooks.Any()) @@ -66,55 +66,38 @@ private void AnalyzeSymbol(SymbolAnalysisContext context) foreach (var attributeData in globalHooks) { - IsGlobalHook(context, attributeData, out var hookLevel); + IsGlobalHook(context, attributeData, out var hookLevel, out var hookType); - var contextType = hookLevel switch - { - HookLevel.Test => WellKnown.AttributeFullyQualifiedClasses.TestContext.WithGlobalPrefix, - HookLevel.Class => WellKnown.AttributeFullyQualifiedClasses.ClassHookContext.WithGlobalPrefix, - HookLevel.Assembly => WellKnown.AttributeFullyQualifiedClasses.AssemblyHookContext.WithGlobalPrefix, - _ => null - }; + var (contextType, contextTypeName) = GetExpectedContext(hookLevel, hookType); if (contextType != null) { var parameterStatus = CheckHookParameters(methodSymbol, contextType); - + switch (parameterStatus) { case HookParameterStatus.NoParameters: - // Informational diagnostic - suggest adding context parameter - var contextTypeName = hookLevel switch + // TestSession/TestDiscovery hooks rarely need their context — don't nudge. + // Test/Class/Assembly still get the info-level suggestion. + if (hookLevel is HookLevel.TestSession or HookLevel.TestDiscovery) { - HookLevel.Test => "TestContext", - HookLevel.Class => "ClassHookContext", - HookLevel.Assembly => "AssemblyHookContext", - _ => "context" - }; + break; + } context.ReportDiagnostic(Diagnostic.Create( - Rules.HookContextParameterOptional, + Rules.HookContextParameterOptional, methodSymbol.Locations.FirstOrDefault(), contextTypeName)); break; - + case HookParameterStatus.Valid: - // No diagnostic needed - parameters are correct break; - + case HookParameterStatus.UnknownParameters: - // Error diagnostic - unknown parameters - var expectedContextTypeName = hookLevel switch - { - HookLevel.Test => "TestContext", - HookLevel.Class => "ClassHookContext", - HookLevel.Assembly => "AssemblyHookContext", - _ => "context" - }; - var firstBadParam = FindFirstUnknownParameter(methodSymbol, contextType!); + var firstBadParam = FindFirstUnknownParameter(methodSymbol, contextType); context.ReportDiagnostic(Diagnostic.Create( Rules.HookUnknownParameters, firstBadParam?.Locations.FirstOrDefault() ?? methodSymbol.Locations.FirstOrDefault(), - expectedContextTypeName)); + contextTypeName)); break; } } @@ -128,17 +111,33 @@ private void AnalyzeSymbol(SymbolAnalysisContext context) } } - private static bool IsGlobalHook(SymbolAnalysisContext context, AttributeData x, [NotNullWhen(true)] out HookLevel? hookLevel) + private static bool IsGlobalHook(SymbolAnalysisContext context, AttributeData x, [NotNullWhen(true)] out HookLevel? hookLevel, [NotNullWhen(true)] out HookType? hookType) { // For standard hooks (Before/After), only Assembly, TestSession, and TestDiscovery are global - if (x.IsStandardHook(context.Compilation, out _, out hookLevel, out _) + if (x.IsStandardHook(context.Compilation, out _, out hookLevel, out hookType) && hookLevel is HookLevel.Assembly or HookLevel.TestSession or HookLevel.TestDiscovery) { return true; } // For Every hooks (BeforeEvery/AfterEvery), all levels (Test, Class, Assembly) are considered global - return x.IsEveryHook(context.Compilation, out _, out hookLevel, out _); + return x.IsEveryHook(context.Compilation, out _, out hookLevel, out hookType); + } + + private static (string? ContextType, string? ContextTypeName) GetExpectedContext(HookLevel? hookLevel, HookType? hookType) + { + return hookLevel switch + { + HookLevel.Test => (WellKnown.AttributeFullyQualifiedClasses.TestContext.WithGlobalPrefix, "TestContext"), + HookLevel.Class => (WellKnown.AttributeFullyQualifiedClasses.ClassHookContext.WithGlobalPrefix, "ClassHookContext"), + HookLevel.Assembly => (WellKnown.AttributeFullyQualifiedClasses.AssemblyHookContext.WithGlobalPrefix, "AssemblyHookContext"), + HookLevel.TestSession => (WellKnown.AttributeFullyQualifiedClasses.TestSessionContext.WithGlobalPrefix, "TestSessionContext"), + HookLevel.TestDiscovery when hookType == HookType.Before + => (WellKnown.AttributeFullyQualifiedClasses.BeforeTestDiscoveryContext.WithGlobalPrefix, "BeforeTestDiscoveryContext"), + HookLevel.TestDiscovery + => (WellKnown.AttributeFullyQualifiedClasses.TestDiscoveryContext.WithGlobalPrefix, "TestDiscoveryContext"), + _ => (null, null) + }; } private enum HookParameterStatus diff --git a/TUnit.Aspire.Tests/TestTraceExporterTests.cs b/TUnit.Aspire.Tests/TestTraceExporterTests.cs index 85e136a780..9f2023b9d9 100644 --- a/TUnit.Aspire.Tests/TestTraceExporterTests.cs +++ b/TUnit.Aspire.Tests/TestTraceExporterTests.cs @@ -41,7 +41,7 @@ public async Task AddToBuilder_ExportsTracesForRegisteredSource() var endpoint = new Uri($"http://127.0.0.1:{server.Port}"); var builder = Sdk.CreateTracerProviderBuilder().AddSource(sourceName); - TestTraceExporter.AddToBuilder(builder, GetCurrentSessionContext(), endpoint); + TestTraceExporter.AddToBuilder(builder, endpoint); using (var activitySource = new ActivitySource(sourceName)) using (var provider = builder.Build()) @@ -65,9 +65,4 @@ public async Task GetTracesEndpoint_AppendsSignalPathWithoutDroppingBasePath() await Assert.That(tracesEndpoint.ToString()).IsEqualTo("http://127.0.0.1:5341/ingest/otlp/v1/traces"); } - - private static TestSessionContext GetCurrentSessionContext() - { - return TestContext.Current!.ClassContext.AssemblyContext.TestSessionContext; - } } diff --git a/TUnit.Aspire/AspireTelemetryHooks.cs b/TUnit.Aspire/AspireTelemetryHooks.cs index 0802f13cba..59acdcbdd4 100644 --- a/TUnit.Aspire/AspireTelemetryHooks.cs +++ b/TUnit.Aspire/AspireTelemetryHooks.cs @@ -12,7 +12,7 @@ namespace TUnit.Aspire; public static class AspireTelemetryHooks { [Before(HookType.TestDiscovery, Order = AutoStart.AutoStartOrder - 1)] - public static void RegisterAspireExporter(TestSessionContext context) + public static void RegisterAspireExporter() { var endpoint = TestTraceExporter.TryGetDashboardEndpoint(); if (endpoint is null) @@ -21,6 +21,6 @@ public static void RegisterAspireExporter(TestSessionContext context) } TUnitOpenTelemetry.Configure(builder => - TestTraceExporter.AddToBuilder(builder, context, endpoint)); + TestTraceExporter.AddToBuilder(builder, endpoint)); } } diff --git a/TUnit.Aspire/Telemetry/TestTraceExporter.cs b/TUnit.Aspire/Telemetry/TestTraceExporter.cs index 7d64697bec..cbf93e8897 100644 --- a/TUnit.Aspire/Telemetry/TestTraceExporter.cs +++ b/TUnit.Aspire/Telemetry/TestTraceExporter.cs @@ -2,7 +2,6 @@ using OpenTelemetry.Exporter; using OpenTelemetry.Resources; using OpenTelemetry.Trace; -using TUnit.Core; namespace TUnit.Aspire.Telemetry; @@ -14,15 +13,16 @@ namespace TUnit.Aspire.Telemetry; internal static class TestTraceExporter { private const string DashboardOtlpEndpointEnvVar = "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL"; + private const string ServiceNameEnvVar = "OTEL_SERVICE_NAME"; private const string DefaultServiceName = "TUnit.Tests"; internal static Uri? TryGetDashboardEndpoint() => TryParseDashboardEndpoint(Environment.GetEnvironmentVariable(DashboardOtlpEndpointEnvVar)); - internal static void AddToBuilder(TracerProviderBuilder builder, TestSessionContext context, Uri endpoint) + internal static void AddToBuilder(TracerProviderBuilder builder, Uri endpoint) { builder - .SetResourceBuilder(CreateResourceBuilder(context)) + .SetResourceBuilder(CreateResourceBuilder()) .AddOtlpExporter(options => { options.Endpoint = GetTracesEndpoint(endpoint); @@ -57,26 +57,24 @@ internal static Uri GetTracesEndpoint(Uri endpoint) return builder.Uri; } - private static ResourceBuilder CreateResourceBuilder(TestSessionContext context) + private static ResourceBuilder CreateResourceBuilder() { - var serviceName = GetServiceName(context); + var serviceName = GetServiceName(); var serviceVersion = typeof(TestTraceExporter).Assembly.GetName().Version?.ToString(); return ResourceBuilder.CreateDefault() .AddService(serviceName: serviceName, serviceVersion: serviceVersion); } - private static string GetServiceName(TestSessionContext context) + private static string GetServiceName() { - var assemblyNames = context.Assemblies - .Select(static assemblyContext => assemblyContext.Assembly.GetName().Name) - .Where(static name => !string.IsNullOrWhiteSpace(name)) - .Distinct(StringComparer.Ordinal) - .Take(2) - .ToArray(); + var fromEnv = Environment.GetEnvironmentVariable(ServiceNameEnvVar); + if (!string.IsNullOrWhiteSpace(fromEnv)) + { + return fromEnv!; + } - return assemblyNames.Length == 1 - ? assemblyNames[0]! - : DefaultServiceName; + return System.Reflection.Assembly.GetEntryAssembly()?.GetName().Name + ?? DefaultServiceName; } } diff --git a/TUnit.Core.SourceGenerator.Tests/TestDiscoveryHookTests.Test.verified.txt b/TUnit.Core.SourceGenerator.Tests/TestDiscoveryHookTests.Test.verified.txt index 5f282702bb..ab6e001e8d 100644 --- a/TUnit.Core.SourceGenerator.Tests/TestDiscoveryHookTests.Test.verified.txt +++ b/TUnit.Core.SourceGenerator.Tests/TestDiscoveryHookTests.Test.verified.txt @@ -1 +1,87 @@ - \ No newline at end of file +// +#pragma warning disable + +#nullable enable +namespace TUnit.Generated; +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverageAttribute] +[global::System.CodeDom.Compiler.GeneratedCode("TUnit", "VERSION_SCRUBBED")] +internal static class TUnit_TestProject_TestDiscoveryHookTests__TestSource +{ + private static readonly global::TUnit.Core.ClassMetadata __classMetadata = global::TUnit.Core.ClassMetadata.GetOrAdd("TestsBase`1:global::TUnit.TestProject.TestDiscoveryHookTests", new global::TUnit.Core.ClassMetadata + { + Type = typeof(global::TUnit.TestProject.TestDiscoveryHookTests), + TypeInfo = new global::TUnit.Core.ConcreteType(typeof(global::TUnit.TestProject.TestDiscoveryHookTests)), + Name = "TestDiscoveryHookTests", + Namespace = "TUnit.TestProject", + Assembly = global::TUnit.Core.AssemblyMetadata.GetOrAdd("TestsBase`1", "TestsBase`1"), + Parameters = global::System.Array.Empty(), + Properties = global::System.Array.Empty(), + Parent = null + }); + private static readonly global::System.Type __classType = typeof(global::TUnit.TestProject.TestDiscoveryHookTests); + private static readonly global::TUnit.Core.MethodMetadata __mm_0 = global::TUnit.Core.MethodMetadataFactory.Create("CustomNamedDiscoveryHooksWereRegisteredAndRan", __classType, typeof(global::System.Threading.Tasks.Task), __classMetadata); + private static global::TUnit.TestProject.TestDiscoveryHookTests __CreateInstance(global::System.Type[] typeArgs, object?[] args) + { + return new global::TUnit.TestProject.TestDiscoveryHookTests(); + } + private static global::System.Threading.Tasks.ValueTask __Invoke(global::TUnit.TestProject.TestDiscoveryHookTests instance, int methodIndex, object?[] args, global::System.Threading.CancellationToken cancellationToken) + { + switch (methodIndex) + { + case 0: + { + try + { + return new global::System.Threading.Tasks.ValueTask(instance.CustomNamedDiscoveryHooksWereRegisteredAndRan()); + } + catch (global::System.Exception ex) + { + return new global::System.Threading.Tasks.ValueTask(global::System.Threading.Tasks.Task.FromException(ex)); + } + } + default: + throw new global::System.ArgumentOutOfRangeException(nameof(methodIndex)); + } + } + private static global::System.Attribute[] __Attributes(int groupIndex) + { + switch (groupIndex) + { + case 0: + { + return + [ + new global::TUnit.Core.TestAttribute(), + new global::TUnit.TestProject.Attributes.EngineTest(global::TUnit.TestProject.Attributes.ExpectedResult.Pass) + ]; + } + default: + throw new global::System.ArgumentOutOfRangeException(nameof(groupIndex)); + } + } + public static readonly global::TUnit.Core.TestEntry[] Entries = new global::TUnit.Core.TestEntry[] + { + new global::TUnit.Core.TestEntry + { + MethodName = "CustomNamedDiscoveryHooksWereRegisteredAndRan", + FullyQualifiedName = "TUnit.TestProject.TestDiscoveryHookTests.CustomNamedDiscoveryHooksWereRegisteredAndRan", + FilePath = @"", + LineNumber = 35, + Categories = global::System.Array.Empty(), + Properties = global::System.Array.Empty(), + HasDataSource = false, + RepeatCount = 0, + DependsOn = global::System.Array.Empty(), + MethodMetadata = __mm_0, + CreateInstance = __CreateInstance, + InvokeBody = __Invoke, + MethodIndex = 0, + CreateAttributes = __Attributes, + AttributeGroupIndex = 0, + }, + }; +} +internal static partial class TUnit_TestRegistration +{ + static readonly int _r_TUnit_TestProject_TestDiscoveryHookTests__TestSource = global::TUnit.Core.SourceRegistrar.RegisterEntries(static () => TUnit_TestProject_TestDiscoveryHookTests__TestSource.Entries); +} diff --git a/TUnit.Core.SourceGenerator/Generators/HookMetadataGenerator.cs b/TUnit.Core.SourceGenerator/Generators/HookMetadataGenerator.cs index dd58f13d42..26ffd36243 100644 --- a/TUnit.Core.SourceGenerator/Generators/HookMetadataGenerator.cs +++ b/TUnit.Core.SourceGenerator/Generators/HookMetadataGenerator.cs @@ -114,7 +114,7 @@ private static void GenerateHookFile(SourceProductionContext context, (HookModel var hookAttribute = context.Attributes[0]; var hookType = GetHookType(hookAttribute); - if (!IsValidHookMethod(methodSymbol, hookType)) + if (!IsValidHookMethod(methodSymbol, hookType, hookKind)) { return null; } @@ -244,7 +244,7 @@ private static void AppendInlineMultilineText(ICodeWriter writer, string text) } } - private static bool IsValidHookMethod(IMethodSymbol method, string hookType) + private static bool IsValidHookMethod(IMethodSymbol method, string hookType, string hookKind) { var returnType = method.ReturnType; if (returnType.SpecialType != SpecialType.System_Void && @@ -271,21 +271,17 @@ private static bool IsValidHookMethod(IMethodSymbol method, string hookType) return method.Parameters.Length == 1; } + var isBefore = hookKind is "Before" or "BeforeEvery"; var expectedContextType = hookType switch { "Test" => "TestContext", "Class" => "ClassHookContext", "Assembly" => "AssemblyHookContext", "TestSession" => "TestSessionContext", - "TestDiscovery" => "TestDiscoveryContext", + "TestDiscovery" => isBefore ? "BeforeTestDiscoveryContext" : "TestDiscoveryContext", _ => null }; - if (hookType == "TestDiscovery" && method.Name.Contains("Before")) - { - expectedContextType = "BeforeTestDiscoveryContext"; - } - if (expectedContextType == null || firstParamNamespace != "TUnit.Core" || firstParamTypeName != expectedContextType) { return false; diff --git a/TUnit.TestProject/TestDiscoveryHookTests.cs b/TUnit.TestProject/TestDiscoveryHookTests.cs index 899a68a61c..2775c3a0c6 100644 --- a/TUnit.TestProject/TestDiscoveryHookTests.cs +++ b/TUnit.TestProject/TestDiscoveryHookTests.cs @@ -1,7 +1,13 @@ -namespace TUnit.TestProject; +using TUnit.TestProject.Attributes; +namespace TUnit.TestProject; + +[EngineTest(ExpectedResult.Pass)] public class TestDiscoveryHookTests { + public static int CustomNamedBeforeDiscoveryCalls; + public static int CustomNamedAfterDiscoveryCalls; + [BeforeEvery(TestDiscovery, Order = 5)] public static void BeforeDiscovery() { @@ -11,4 +17,24 @@ public static void BeforeDiscovery() public static void AfterDiscovery() { } + + // Regression: method name without "Before"/"After" must still resolve correct + // context type from the [Before]/[After] attribute kind, not from the method name. + [Before(TestDiscovery)] + public static void CustomNamedDiscoveryHook(BeforeTestDiscoveryContext context) + { + System.Threading.Interlocked.Increment(ref CustomNamedBeforeDiscoveryCalls); + } + + [After(TestDiscovery)] + public static void AnotherCustomNamedDiscoveryHook(TestDiscoveryContext context) + { + System.Threading.Interlocked.Increment(ref CustomNamedAfterDiscoveryCalls); + } + + [Test] + public async Task CustomNamedDiscoveryHooksWereRegisteredAndRan() + { + await Assert.That(CustomNamedBeforeDiscoveryCalls).IsGreaterThan(0); + } }