Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 33 additions & 34 deletions TUnit.Analyzers/GlobalTestHooksAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down Expand Up @@ -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;
}
}
Expand All @@ -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
Expand Down
7 changes: 1 addition & 6 deletions TUnit.Aspire.Tests/TestTraceExporterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand All @@ -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;
}
}
4 changes: 2 additions & 2 deletions TUnit.Aspire/AspireTelemetryHooks.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -21,6 +21,6 @@ public static void RegisterAspireExporter(TestSessionContext context)
}

TUnitOpenTelemetry.Configure(builder =>
TestTraceExporter.AddToBuilder(builder, context, endpoint));
TestTraceExporter.AddToBuilder(builder, endpoint));
}
}
28 changes: 13 additions & 15 deletions TUnit.Aspire/Telemetry/TestTraceExporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
using OpenTelemetry.Exporter;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
using TUnit.Core;

namespace TUnit.Aspire.Telemetry;

Expand All @@ -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);
Expand Down Expand Up @@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -1 +1,87 @@

// <auto-generated/>
#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<global::TUnit.Core.ParameterMetadata>(),
Properties = global::System.Array.Empty<global::TUnit.Core.PropertyMetadata>(),
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<global::TUnit.TestProject.TestDiscoveryHookTests>[] Entries = new global::TUnit.Core.TestEntry<global::TUnit.TestProject.TestDiscoveryHookTests>[]
{
new global::TUnit.Core.TestEntry<global::TUnit.TestProject.TestDiscoveryHookTests>
{
MethodName = "CustomNamedDiscoveryHooksWereRegisteredAndRan",
FullyQualifiedName = "TUnit.TestProject.TestDiscoveryHookTests.CustomNamedDiscoveryHooksWereRegisteredAndRan",
FilePath = @"",
LineNumber = 35,
Categories = global::System.Array.Empty<string>(),
Properties = global::System.Array.Empty<string>(),
HasDataSource = false,
RepeatCount = 0,
DependsOn = global::System.Array.Empty<string>(),
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<global::TUnit.TestProject.TestDiscoveryHookTests>(static () => TUnit_TestProject_TestDiscoveryHookTests__TestSource.Entries);
}
12 changes: 4 additions & 8 deletions TUnit.Core.SourceGenerator/Generators/HookMetadataGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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 &&
Expand All @@ -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;
Expand Down
28 changes: 27 additions & 1 deletion TUnit.TestProject/TestDiscoveryHookTests.cs
Original file line number Diff line number Diff line change
@@ -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()
{
Expand All @@ -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);
}
}
Loading