Skip to content
18 changes: 15 additions & 3 deletions TUnit.Core/Attributes/Executors/CultureAttribute.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
using System.Globalization;
using System.Globalization;
using TUnit.Core.Interfaces;

namespace TUnit.Core.Executors;

[AttributeUsage(AttributeTargets.Assembly | AttributeTargets.Class | AttributeTargets.Method)]
public class CultureAttribute(CultureInfo cultureInfo) : TUnitAttribute, ITestRegisteredEventReceiver, IScopedAttribute
public class CultureAttribute(CultureInfo cultureInfo) : TUnitAttribute, ITestRegisteredEventReceiver, IHookRegisteredEventReceiver, IScopedAttribute
{
private CultureExecutor? _executor;
private CultureExecutor Executor => _executor ??= new CultureExecutor(cultureInfo);

public CultureAttribute(string cultureName) : this(CultureInfo.GetCultureInfo(cultureName))
{
}
Expand All @@ -19,7 +22,16 @@ public CultureAttribute(string cultureName) : this(CultureInfo.GetCultureInfo(cu
/// <inheritdoc />
public ValueTask OnTestRegistered(TestRegisteredContext context)
{
context.SetTestExecutor(new CultureExecutor(cultureInfo));
var executor = Executor;
context.SetTestExecutor(executor);
context.SetHookExecutor(executor);
return default(ValueTask);
}

/// <inheritdoc />
public ValueTask OnHookRegistered(HookRegisteredContext context)
{
context.HookExecutor = Executor;
return default(ValueTask);
}
}
17 changes: 14 additions & 3 deletions TUnit.Core/Attributes/Executors/STAThreadExecutorAttribute.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
using System.Runtime.Versioning;
using System.Runtime.Versioning;
using TUnit.Core.Interfaces;

namespace TUnit.Core.Executors;

[SupportedOSPlatform("windows")]
[AttributeUsage(AttributeTargets.Assembly | AttributeTargets.Class | AttributeTargets.Method)]
public class STAThreadExecutorAttribute : TUnitAttribute, ITestRegisteredEventReceiver, IScopedAttribute
public class STAThreadExecutorAttribute : TUnitAttribute, ITestRegisteredEventReceiver, IHookRegisteredEventReceiver, IScopedAttribute
{
private STAThreadExecutor? _executor;
private STAThreadExecutor Executor => _executor ??= new STAThreadExecutor();

/// <inheritdoc />
public int Order => 0;

Expand All @@ -16,8 +19,16 @@ public class STAThreadExecutorAttribute : TUnitAttribute, ITestRegisteredEventRe
/// <inheritdoc />
public ValueTask OnTestRegistered(TestRegisteredContext context)
{
var executor = new STAThreadExecutor();
var executor = Executor;
context.SetTestExecutor(executor);
context.SetHookExecutor(executor);
return default(ValueTask);
}

/// <inheritdoc />
public ValueTask OnHookRegistered(HookRegisteredContext context)
{
context.HookExecutor = Executor;
return default(ValueTask);
}
}
57 changes: 52 additions & 5 deletions TUnit.Core/Attributes/Executors/TestExecutorAttribute.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
using System.Diagnostics.CodeAnalysis;
using System.Diagnostics.CodeAnalysis;
using TUnit.Core.Interfaces;

namespace TUnit.Core.Executors;

[AttributeUsage(AttributeTargets.Assembly | AttributeTargets.Class | AttributeTargets.Method)]
public sealed class TestExecutorAttribute<T> : TUnitAttribute, ITestRegisteredEventReceiver, IScopedAttribute where T : ITestExecutor, new()
public sealed class TestExecutorAttribute<T> : TUnitAttribute, ITestRegisteredEventReceiver, IHookRegisteredEventReceiver, IScopedAttribute where T : ITestExecutor, new()
{
private T? _executor;
private T Executor => _executor ??= new T();

/// <inheritdoc />
public int Order => 0;

Expand All @@ -15,14 +18,43 @@ namespace TUnit.Core.Executors;
/// <inheritdoc />
public ValueTask OnTestRegistered(TestRegisteredContext context)
{
context.SetTestExecutor(new T());
var executor = Executor;
context.SetTestExecutor(executor);
if (executor is IHookExecutor hookExecutor)
{
context.SetHookExecutor(hookExecutor);
}
return default(ValueTask);
}

/// <inheritdoc />
public ValueTask OnHookRegistered(HookRegisteredContext context)
{
if (Executor is IHookExecutor hookExecutor)
{
context.HookExecutor = hookExecutor;
}
return default(ValueTask);
}
}

[AttributeUsage(AttributeTargets.Assembly | AttributeTargets.Class | AttributeTargets.Method)]
public sealed class TestExecutorAttribute([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type type) : TUnitAttribute, ITestRegisteredEventReceiver, IScopedAttribute
public sealed class TestExecutorAttribute : TUnitAttribute, ITestRegisteredEventReceiver, IHookRegisteredEventReceiver, IScopedAttribute
{
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)]
private readonly Type _type;

private readonly bool _isHookExecutor;
private ITestExecutor? _executor;

public TestExecutorAttribute([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type type)
{
_type = type;
_isHookExecutor = typeof(IHookExecutor).IsAssignableFrom(type);
}

private ITestExecutor Executor => _executor ??= (ITestExecutor) Activator.CreateInstance(_type)!;

/// <inheritdoc />
public int Order => 0;

Expand All @@ -32,7 +64,22 @@ public sealed class TestExecutorAttribute([DynamicallyAccessedMembers(Dynamicall
/// <inheritdoc />
public ValueTask OnTestRegistered(TestRegisteredContext context)
{
context.SetTestExecutor((ITestExecutor) Activator.CreateInstance(type)!);
var executor = Executor;
context.SetTestExecutor(executor);
if (_isHookExecutor)
{
context.SetHookExecutor((IHookExecutor) executor);
}
return default(ValueTask);
}

/// <inheritdoc />
public ValueTask OnHookRegistered(HookRegisteredContext context)
{
if (_isHookExecutor)
{
context.HookExecutor = (IHookExecutor) Executor;
}
return default(ValueTask);
}
}
21 changes: 12 additions & 9 deletions TUnit.Core/Contexts/HookRegisteredContext.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using TUnit.Core.Hooks;
using TUnit.Core.Interfaces;

namespace TUnit.Core;

Expand All @@ -7,20 +8,22 @@ namespace TUnit.Core;
/// </summary>
public class HookRegisteredContext
{
private TimeSpan? _timeout;

public HookMethod HookMethod { get; }
public string HookName => HookMethod.Name;

/// <summary>
/// Gets or sets the timeout for this hook
/// </summary>
public TimeSpan? Timeout
{
get => _timeout;
set => _timeout = value;
}

public TimeSpan? Timeout { get; set; }

/// <summary>
/// Gets or sets the hook executor that will be used to invoke this hook.
/// Set by <see cref="IHookRegisteredEventReceiver"/> implementations (e.g. <c>CultureAttribute</c>,
/// <c>STAThreadExecutorAttribute</c>) to wrap hook invocation in custom execution logic.
/// If left <c>null</c>, the hook's default executor is used.
/// </summary>
public IHookExecutor? HookExecutor { get; set; }

public HookRegisteredContext(HookMethod hookMethod)
{
HookMethod = hookMethod;
Expand Down
14 changes: 2 additions & 12 deletions TUnit.Core/Hooks/AfterTestHookMethod.cs
Original file line number Diff line number Diff line change
@@ -1,20 +1,10 @@
namespace TUnit.Core.Hooks;
namespace TUnit.Core.Hooks;

public record AfterTestHookMethod : StaticHookMethod<TestContext>
{
public override ValueTask ExecuteAsync(TestContext context, CancellationToken cancellationToken)
{
// Check if a custom hook executor has been set (e.g., via SetHookExecutor())
// This ensures static hooks respect the custom executor even in AOT/trimmed builds
if (context.CustomHookExecutor != null)
{
return context.CustomHookExecutor.ExecuteAfterTestHook(MethodInfo, context,
() => Body!.Invoke(context, cancellationToken)
);
}

// Use the default executor specified at hook registration time
return HookExecutor.ExecuteAfterTestHook(MethodInfo, context,
return ResolveEffectiveExecutor(context).ExecuteAfterTestHook(MethodInfo, context,
() => Body!.Invoke(context, cancellationToken)
);
}
Expand Down
14 changes: 2 additions & 12 deletions TUnit.Core/Hooks/BeforeTestHookMethod.cs
Original file line number Diff line number Diff line change
@@ -1,20 +1,10 @@
namespace TUnit.Core.Hooks;
namespace TUnit.Core.Hooks;

public record BeforeTestHookMethod : StaticHookMethod<TestContext>
{
public override ValueTask ExecuteAsync(TestContext context, CancellationToken cancellationToken)
{
// Check if a custom hook executor has been set (e.g., via SetHookExecutor())
// This ensures static hooks respect the custom executor even in AOT/trimmed builds
if (context.CustomHookExecutor != null)
{
return context.CustomHookExecutor.ExecuteBeforeTestHook(MethodInfo, context,
() => Body!.Invoke(context, cancellationToken)
);
}

// Use the default executor specified at hook registration time
return HookExecutor.ExecuteBeforeTestHook(MethodInfo, context,
return ResolveEffectiveExecutor(context).ExecuteBeforeTestHook(MethodInfo, context,
() => Body!.Invoke(context, cancellationToken)
);
}
Expand Down
29 changes: 28 additions & 1 deletion TUnit.Core/Hooks/HookMethod.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,34 @@ public abstract record HookMethod
/// </summary>
public TimeSpan? Timeout { get; internal set; } = Defaults.HookTimeout;

public required IHookExecutor HookExecutor { get; init; }
private IHookExecutor _hookExecutor = DefaultExecutor.Instance;
private bool _hookExecutorIsExplicit;

public required IHookExecutor HookExecutor
{
get => _hookExecutor;
init
{
_hookExecutor = value;
// An init-time value other than DefaultExecutor means an explicit [HookExecutor<T>]
// attribute was present at discovery time (source-gen or reflection).
_hookExecutorIsExplicit = !ReferenceEquals(value, DefaultExecutor.Instance);
}
}

internal void SetHookExecutor(IHookExecutor executor) => _hookExecutor = executor;

// Explicit [HookExecutor<T>] on the hook method itself always wins.
// Otherwise, prefer the per-test CustomHookExecutor (set via OnTestRegistered with
// ScopedAttributeFilter, so it reflects the most-specific scoped attribute for this
// test — e.g. method-level [Culture] over class-level). Fall back to _hookExecutor
// which may have been set via OnHookRegistered from class/assembly-level attributes.
internal IHookExecutor ResolveEffectiveExecutor(TestContext? testContext) =>
_hookExecutorIsExplicit
? _hookExecutor
: testContext?.CustomHookExecutor is { } custom
? custom
: _hookExecutor;

public required int Order { get; init; }

Expand Down
2 changes: 1 addition & 1 deletion TUnit.Core/Hooks/InstanceHookMethod.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ public ValueTask ExecuteAsync(TestContext context, CancellationToken cancellatio
throw new InvalidOperationException($"Cannot execute instance hook {Name} because the test instance has not been created yet. This is likely a framework bug.");
}

return HookExecutor.ExecuteBeforeTestHook(MethodInfo, context,
return ResolveEffectiveExecutor(context).ExecuteBeforeTestHook(MethodInfo, context,
() => Body!.Invoke(context.Metadata.TestDetails.ClassInstance, context, cancellationToken)
);
}
Expand Down
39 changes: 5 additions & 34 deletions TUnit.Engine/Discovery/ReflectionHookDiscoveryService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -999,39 +999,10 @@ private static IHookExecutor GetHookExecutor(MethodInfo method)
}
}

return new DefaultHookExecutor();
// Share the same sentinel singleton as source-gen so the precedence check in
// HookMethod.ResolveEffectiveExecutor (ReferenceEquals DefaultExecutor.Instance)
// recognizes this as "no explicit executor" and lets TestContext.CustomHookExecutor
// fill in via SetHookExecutor (#2666).
return DefaultExecutor.Instance;
}
}

internal class DefaultHookExecutor : IHookExecutor
{
public ValueTask ExecuteBeforeTestDiscoveryHook(MethodMetadata testMethod, BeforeTestDiscoveryContext context, Func<ValueTask> action)
=> action();

public ValueTask ExecuteAfterTestDiscoveryHook(MethodMetadata testMethod, TestDiscoveryContext context, Func<ValueTask> action)
=> action();

public ValueTask ExecuteBeforeAssemblyHook(MethodMetadata testMethod, AssemblyHookContext context, Func<ValueTask> action)
=> action();

public ValueTask ExecuteAfterAssemblyHook(MethodMetadata testMethod, AssemblyHookContext context, Func<ValueTask> action)
=> action();

public ValueTask ExecuteBeforeClassHook(MethodMetadata testMethod, ClassHookContext context, Func<ValueTask> action)
=> action();

public ValueTask ExecuteAfterClassHook(MethodMetadata testMethod, ClassHookContext context, Func<ValueTask> action)
=> action();

public ValueTask ExecuteBeforeTestHook(MethodMetadata testMethod, TestContext context, Func<ValueTask> action)
=> action();

public ValueTask ExecuteAfterTestHook(MethodMetadata testMethod, TestContext context, Func<ValueTask> action)
=> action();

public ValueTask ExecuteBeforeTestSessionHook(MethodMetadata testMethod, TestSessionContext context, Func<ValueTask> action)
=> action();

public ValueTask ExecuteAfterTestSessionHook(MethodMetadata testMethod, TestSessionContext context, Func<ValueTask> action)
=> action();
}
Loading
Loading