diff --git a/TUnit.Core/Contexts/TestRegisteredContext.cs b/TUnit.Core/Contexts/TestRegisteredContext.cs index 49fbbbdc24..7b3786ff9d 100644 --- a/TUnit.Core/Contexts/TestRegisteredContext.cs +++ b/TUnit.Core/Contexts/TestRegisteredContext.cs @@ -34,6 +34,15 @@ public void SetTestExecutor(ITestExecutor executor) DiscoveredTest.TestExecutor = executor; } + /// + /// Sets a custom hook executor that will be used for all test-level hooks (Before/After Test). + /// This allows you to wrap hook execution in custom logic (e.g., running on a specific thread). + /// + public void SetHookExecutor(IHookExecutor executor) + { + TestContext.CustomHookExecutor = executor; + } + /// /// Sets the parallel limiter for the test /// diff --git a/TUnit.Core/Hooks/AfterTestHookMethod.cs b/TUnit.Core/Hooks/AfterTestHookMethod.cs index 510ffa81bf..90ac145667 100644 --- a/TUnit.Core/Hooks/AfterTestHookMethod.cs +++ b/TUnit.Core/Hooks/AfterTestHookMethod.cs @@ -4,6 +4,16 @@ public record AfterTestHookMethod : StaticHookMethod { 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, () => Body!.Invoke(context, cancellationToken) ); diff --git a/TUnit.Core/Hooks/BeforeTestHookMethod.cs b/TUnit.Core/Hooks/BeforeTestHookMethod.cs index eea40c6cc1..867af57b7c 100644 --- a/TUnit.Core/Hooks/BeforeTestHookMethod.cs +++ b/TUnit.Core/Hooks/BeforeTestHookMethod.cs @@ -4,6 +4,16 @@ public record BeforeTestHookMethod : StaticHookMethod { 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, () => Body!.Invoke(context, cancellationToken) ); diff --git a/TUnit.Core/TestContext.cs b/TUnit.Core/TestContext.cs index e585327cb0..125a72f85d 100644 --- a/TUnit.Core/TestContext.cs +++ b/TUnit.Core/TestContext.cs @@ -103,6 +103,12 @@ public static string WorkingDirectory public Type? DisplayNameFormatter { get; set; } + /// + /// Custom hook executor that overrides the default hook executor for all test-level hooks. + /// Set via TestRegisteredContext.SetHookExecutor() during test registration. + /// + public IHookExecutor? CustomHookExecutor { get; set; } + public Func>? RetryFunc { get; set; } // New: Support multiple parallel constraints diff --git a/TUnit.Engine/Building/TestBuilder.cs b/TUnit.Engine/Building/TestBuilder.cs index 133dff646a..020cb6a06d 100644 --- a/TUnit.Engine/Building/TestBuilder.cs +++ b/TUnit.Engine/Building/TestBuilder.cs @@ -6,6 +6,7 @@ using TUnit.Core.Interfaces; using TUnit.Core.Services; using TUnit.Engine.Building.Interfaces; +using TUnit.Engine.Extensions; using TUnit.Engine.Helpers; using TUnit.Engine.Services; using TUnit.Engine.Utilities; @@ -20,6 +21,7 @@ internal sealed class TestBuilder : ITestBuilder private readonly PropertyInjectionService _propertyInjectionService; private readonly DataSourceInitializer _dataSourceInitializer; private readonly Discovery.IHookDiscoveryService _hookDiscoveryService; + private readonly TestArgumentRegistrationService _testArgumentRegistrationService; public TestBuilder( string sessionId, @@ -27,7 +29,8 @@ public TestBuilder( IContextProvider contextProvider, PropertyInjectionService propertyInjectionService, DataSourceInitializer dataSourceInitializer, - Discovery.IHookDiscoveryService hookDiscoveryService) + Discovery.IHookDiscoveryService hookDiscoveryService, + TestArgumentRegistrationService testArgumentRegistrationService) { _sessionId = sessionId; _hookDiscoveryService = hookDiscoveryService; @@ -35,6 +38,7 @@ public TestBuilder( _contextProvider = contextProvider; _propertyInjectionService = propertyInjectionService; _dataSourceInitializer = dataSourceInitializer; + _testArgumentRegistrationService = testArgumentRegistrationService; } /// @@ -764,8 +768,8 @@ public async Task BuildTestAsync(TestMetadata metadata, // Arguments will be tracked by TestArgumentTrackingService during TestRegistered event // This ensures proper reference counting for shared instances - await InvokeDiscoveryEventReceiversAsync(context); - + // Create the test object BEFORE invoking event receivers + // This ensures context.InternalExecutableTest is set for error handling in registration var creationContext = new ExecutableTestCreationContext { TestId = testId, @@ -778,7 +782,27 @@ public async Task BuildTestAsync(TestMetadata metadata, ResolvedClassGenericArguments = testData.ResolvedClassGenericArguments }; - return metadata.CreateExecutableTestFactory(creationContext, metadata); + var test = metadata.CreateExecutableTestFactory(creationContext, metadata); + + // Set InternalExecutableTest so it's available during registration for error handling + context.InternalExecutableTest = test; + + // Invoke test registered event receivers BEFORE discovery event receivers + // This is critical for allowing attributes to set custom hook executors + try + { + await InvokeTestRegisteredEventReceiversAsync(context); + } + catch (Exception ex) + { + // Property registration or other registration logic failed + // Mark the test as failed immediately, as the old code did + test.SetResult(TestState.Failed, ex); + } + + await InvokeDiscoveryEventReceiversAsync(context); + + return test; } /// @@ -854,6 +878,37 @@ private async ValueTask CreateTestContextAsync(string testId, TestM return context; } +#if NET6_0_OR_GREATER + [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode("Type comes from runtime objects that cannot be annotated")] +#endif + private async Task InvokeTestRegisteredEventReceiversAsync(TestContext context) + { + var discoveredTest = new DiscoveredTest + { + TestContext = context + }; + + var registeredContext = new TestRegisteredContext(context) + { + DiscoveredTest = discoveredTest + }; + + context.InternalDiscoveredTest = discoveredTest; + + // First, invoke the global test argument registration service to register shared instances + await _testArgumentRegistrationService.OnTestRegistered(registeredContext); + + var eventObjects = context.GetEligibleEventObjects(); + + foreach (var receiver in eventObjects.OfType()) + { + await receiver.OnTestRegistered(registeredContext); + } + } + +#if NET6_0_OR_GREATER + [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode("Scoped attribute filtering uses Type.GetInterfaces and reflection")] +#endif private async Task InvokeDiscoveryEventReceiversAsync(TestContext context) { var discoveredContext = new DiscoveredTestContext( @@ -877,8 +932,6 @@ private async Task CreateFailedTestForDataGenerationErro var testDetails = await CreateFailedTestDetails(metadata, testId); var context = CreateFailedTestContext(metadata, testDetails); - await InvokeDiscoveryEventReceiversAsync(context); - return new FailedExecutableTest(exception) { TestId = testId, diff --git a/TUnit.Engine/Framework/TUnitServiceProvider.cs b/TUnit.Engine/Framework/TUnitServiceProvider.cs index a266526c39..673e73c91a 100644 --- a/TUnit.Engine/Framework/TUnitServiceProvider.cs +++ b/TUnit.Engine/Framework/TUnitServiceProvider.cs @@ -166,7 +166,7 @@ public TUnitServiceProvider(IExtension extension, } var testBuilder = Register( - new TestBuilder(TestSessionId, EventReceiverOrchestrator, ContextProvider, PropertyInjectionService, DataSourceInitializer, hookDiscoveryService)); + new TestBuilder(TestSessionId, EventReceiverOrchestrator, ContextProvider, PropertyInjectionService, DataSourceInitializer, hookDiscoveryService, testArgumentRegistrationService)); TestBuilderPipeline = Register( new TestBuilderPipeline( diff --git a/TUnit.Engine/Helpers/HookTimeoutHelper.cs b/TUnit.Engine/Helpers/HookTimeoutHelper.cs index 7205caaf05..155b6c34a1 100644 --- a/TUnit.Engine/Helpers/HookTimeoutHelper.cs +++ b/TUnit.Engine/Helpers/HookTimeoutHelper.cs @@ -1,4 +1,6 @@ +using TUnit.Core; using TUnit.Core.Hooks; +using TUnit.Core.Interfaces; namespace TUnit.Engine.Helpers; @@ -15,11 +17,14 @@ public static Func CreateTimeoutHookAction( T context, CancellationToken cancellationToken) { + // CENTRAL POINT: At execution time, check if we should use a custom hook executor + // This happens AFTER OnTestRegistered, so CustomHookExecutor will be set if the user called SetHookExecutor var timeout = hook.Timeout; + if (timeout == null) { - // No timeout specified, execute normally - return async () => await hook.ExecuteAsync(context, cancellationToken); + // No timeout specified, execute with potential custom executor + return async () => await ExecuteHookWithPotentialCustomExecutor(hook, context, cancellationToken); } return async () => @@ -30,7 +35,7 @@ public static Func CreateTimeoutHookAction( try { - await hook.ExecuteAsync(context, cts.Token); + await ExecuteHookWithPotentialCustomExecutor(hook, context, cts.Token); } catch (OperationCanceledException) when (cts.IsCancellationRequested && !cancellationToken.IsCancellationRequested) { @@ -39,6 +44,40 @@ public static Func CreateTimeoutHookAction( }; } + /// + /// Executes a hook, using a custom executor if one is set on the TestContext + /// + private static ValueTask ExecuteHookWithPotentialCustomExecutor(StaticHookMethod hook, T context, CancellationToken cancellationToken) + { + // Check if this is a TestContext with a custom hook executor + if (context is TestContext testContext && testContext.CustomHookExecutor != null) + { + // BYPASS the hook's default executor and call the custom executor directly with the hook's body + var customExecutor = testContext.CustomHookExecutor; + + // Determine which executor method to call based on hook type + if (hook is BeforeTestHookMethod || hook is InstanceHookMethod) + { + return customExecutor.ExecuteBeforeTestHook( + hook.MethodInfo, + testContext, + () => hook.Body!.Invoke(context, cancellationToken) + ); + } + else if (hook is AfterTestHookMethod) + { + return customExecutor.ExecuteAfterTestHook( + hook.MethodInfo, + testContext, + () => hook.Body!.Invoke(context, cancellationToken) + ); + } + } + + // No custom executor, use the hook's default executor + return hook.ExecuteAsync(context, cancellationToken); + } + /// /// Creates a timeout-aware action wrapper for a hook delegate /// @@ -74,6 +113,8 @@ public static Func CreateTimeoutHookAction( /// /// Creates a timeout-aware action wrapper for a hook delegate that returns ValueTask + /// This overload is used for instance hooks (InstanceHookMethod) + /// Custom executor handling for instance hooks is done in HookCollectionService.CreateInstanceHookDelegateAsync /// public static Func CreateTimeoutHookAction( Func hookDelegate, diff --git a/TUnit.Engine/Services/HookCollectionService.cs b/TUnit.Engine/Services/HookCollectionService.cs index 902f7ed057..ab31b36e89 100644 --- a/TUnit.Engine/Services/HookCollectionService.cs +++ b/TUnit.Engine/Services/HookCollectionService.cs @@ -621,14 +621,41 @@ private async Task> CreateInstanceHoo return async (context, cancellationToken) => { - var timeoutAction = HookTimeoutHelper.CreateTimeoutHookAction( - (ctx, ct) => hook.ExecuteAsync(ctx, ct), - context, - hook.Timeout, - hook.Name, - cancellationToken); + // Check at EXECUTION time if a custom executor should be used + if (context.CustomHookExecutor != null) + { + // BYPASS the hook's default executor and call the custom executor directly + var customExecutor = context.CustomHookExecutor; - await timeoutAction(); + // Skip skipped test instances + if (context.TestDetails.ClassInstance is SkippedTestInstance) + { + return; + } + + if (context.TestDetails.ClassInstance is PlaceholderInstance) + { + throw new InvalidOperationException($"Cannot execute instance hook {hook.Name} because the test instance has not been created yet. This is likely a framework bug."); + } + + await customExecutor.ExecuteBeforeTestHook( + hook.MethodInfo, + context, + () => hook.Body!.Invoke(context.TestDetails.ClassInstance, context, cancellationToken) + ); + } + else + { + // No custom executor, use normal execution path + var timeoutAction = HookTimeoutHelper.CreateTimeoutHookAction( + (ctx, ct) => hook.ExecuteAsync(ctx, ct), + context, + hook.Timeout, + hook.Name, + cancellationToken); + + await timeoutAction(); + } }; } diff --git a/TUnit.Engine/Services/TestArgumentRegistrationService.cs b/TUnit.Engine/Services/TestArgumentRegistrationService.cs index 5698233ef3..d92139a881 100644 --- a/TUnit.Engine/Services/TestArgumentRegistrationService.cs +++ b/TUnit.Engine/Services/TestArgumentRegistrationService.cs @@ -139,24 +139,21 @@ await _objectRegistrationService.RegisterObjectAsync( } catch (Exception ex) { - // Capture the exception for this property - mark the test as failed + // Capture the exception for this property and re-throw + // The test building process will handle marking it as failed var exceptionMessage = $"Failed to generate data for property '{metadata.PropertyName}': {ex.Message}"; var propertyException = new InvalidOperationException(exceptionMessage, ex); - - // Mark the test as failed immediately during registration - testContext.InternalExecutableTest.SetResult(TestState.Failed, propertyException); - return; // Stop processing further properties for this test + throw propertyException; } } } catch (Exception ex) { - // Capture any top-level exceptions (e.g., getting property source) + // Capture any top-level exceptions (e.g., getting property source) and re-throw + // The test building process will handle marking it as failed var exceptionMessage = $"Failed to register properties for test '{testContext.TestDetails.TestName}': {ex.Message}"; var registrationException = new InvalidOperationException(exceptionMessage, ex); - - // Mark the test as failed immediately during registration - testContext.InternalExecutableTest.SetResult(TestState.Failed, registrationException); + throw registrationException; } } } diff --git a/TUnit.Engine/Services/TestFilterService.cs b/TUnit.Engine/Services/TestFilterService.cs index 7854b9f9c1..ddc8998467 100644 --- a/TUnit.Engine/Services/TestFilterService.cs +++ b/TUnit.Engine/Services/TestFilterService.cs @@ -70,7 +70,16 @@ private async Task RegisterTest(AbstractExecutableTest test) test.Context.InternalDiscoveredTest = discoveredTest; - await testArgumentRegistrationService.OnTestRegistered(registeredContext); + try + { + await testArgumentRegistrationService.OnTestRegistered(registeredContext); + } + catch (Exception ex) + { + // Mark the test as failed and skip further event receiver processing + test.SetResult(TestState.Failed, ex); + return; + } var eventObjects = test.Context.GetEligibleEventObjects(); diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt index de865ee675..098be8d6d7 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt @@ -1268,6 +1268,7 @@ namespace public .CancellationToken CancellationToken { get; set; } public .ClassHookContext ClassContext { get; } public int CurrentRetryAttempt { get; } + public .? CustomHookExecutor { get; set; } public .<.TestDetails> Dependencies { get; } public ? DisplayNameFormatter { get; set; } public .TestContextEvents Events { get; } @@ -1471,6 +1472,7 @@ namespace public .TestContext TestContext { get; } public .TestDetails TestDetails { get; } public string TestName { get; } + public void SetHookExecutor(. executor) { } public void SetParallelLimiter(. parallelLimit) { } public void SetTestExecutor(. executor) { } } diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt index f7b3d69196..cec9588a0a 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt @@ -1268,6 +1268,7 @@ namespace public .CancellationToken CancellationToken { get; set; } public .ClassHookContext ClassContext { get; } public int CurrentRetryAttempt { get; } + public .? CustomHookExecutor { get; set; } public .<.TestDetails> Dependencies { get; } public ? DisplayNameFormatter { get; set; } public .TestContextEvents Events { get; } @@ -1471,6 +1472,7 @@ namespace public .TestContext TestContext { get; } public .TestDetails TestDetails { get; } public string TestName { get; } + public void SetHookExecutor(. executor) { } public void SetParallelLimiter(. parallelLimit) { } public void SetTestExecutor(. executor) { } } diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt index c7e96f00ab..475f2d4304 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt @@ -1268,6 +1268,7 @@ namespace public .CancellationToken CancellationToken { get; set; } public .ClassHookContext ClassContext { get; } public int CurrentRetryAttempt { get; } + public .? CustomHookExecutor { get; set; } public .<.TestDetails> Dependencies { get; } public ? DisplayNameFormatter { get; set; } public .TestContextEvents Events { get; } @@ -1471,6 +1472,7 @@ namespace public .TestContext TestContext { get; } public .TestDetails TestDetails { get; } public string TestName { get; } + public void SetHookExecutor(. executor) { } public void SetParallelLimiter(. parallelLimit) { } public void SetTestExecutor(. executor) { } } diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt index 2f9c6ad709..cfa7c50d42 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt @@ -1222,6 +1222,7 @@ namespace public .CancellationToken CancellationToken { get; set; } public .ClassHookContext ClassContext { get; } public int CurrentRetryAttempt { get; } + public .? CustomHookExecutor { get; set; } public .<.TestDetails> Dependencies { get; } public ? DisplayNameFormatter { get; set; } public .TestContextEvents Events { get; } @@ -1423,6 +1424,7 @@ namespace public .TestContext TestContext { get; } public .TestDetails TestDetails { get; } public string TestName { get; } + public void SetHookExecutor(. executor) { } public void SetParallelLimiter(. parallelLimit) { } public void SetTestExecutor(. executor) { } } diff --git a/TUnit.TestProject/SetHookExecutorTests.cs b/TUnit.TestProject/SetHookExecutorTests.cs new file mode 100644 index 0000000000..420fbe9ae5 --- /dev/null +++ b/TUnit.TestProject/SetHookExecutorTests.cs @@ -0,0 +1,119 @@ +using TUnit.Core.Executors; +using TUnit.Core.Interfaces; +using TUnit.TestProject.Attributes; +using TUnit.TestProject.TestExecutors; + +namespace TUnit.TestProject; + +/// +/// Attribute that sets both test and hook executors - mimics the user's [Dispatch] attribute from issue #2666 +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] +public class SetBothExecutorsAttribute : Attribute, ITestRegisteredEventReceiver +{ + public int Order => 0; + + public ValueTask OnTestRegistered(TestRegisteredContext context) + { + // Set both test and hook executors to use the same custom executor + // This is the key feature - users can now wrap all methods (test + hooks) in their custom dispatcher + var customExecutor = new CrossPlatformTestExecutor(); + context.SetTestExecutor(customExecutor); + context.SetHookExecutor(customExecutor); + + return default; + } +} + +/// +/// Tests demonstrating SetHookExecutor functionality for issue #2666 +/// Users can use an attribute that calls context.SetHookExecutor() to wrap both tests and their hooks in the same executor +/// +[EngineTest(ExpectedResult.Pass)] +[SetBothExecutors] // This attribute sets both executors +public class SetHookExecutorTests +{ + private static bool _beforeTestHookExecutedWithCustomExecutor; + private static bool _afterTestHookExecutedWithCustomExecutor; + + [Before(Test)] + public async Task BeforeTestHook(TestContext context) + { + // This hook should execute with the custom executor set by the attribute + await Assert.That(Thread.CurrentThread.Name).IsEqualTo("CrossPlatformTestExecutor"); + await Assert.That(CrossPlatformTestExecutor.IsRunningInTestExecutor.Value).IsTrue(); + _beforeTestHookExecutedWithCustomExecutor = true; + } + + [After(Test)] + public async Task AfterTestHook(TestContext context) + { + // This hook should execute with the custom executor set by the attribute + await Assert.That(Thread.CurrentThread.Name).IsEqualTo("CrossPlatformTestExecutor"); + await Assert.That(CrossPlatformTestExecutor.IsRunningInTestExecutor.Value).IsTrue(); + _afterTestHookExecutedWithCustomExecutor = true; + } + + [Test] + public async Task Test_ExecutesInCustomExecutor() + { + // Test should execute in custom executor + await Assert.That(Thread.CurrentThread.Name).IsEqualTo("CrossPlatformTestExecutor"); + await Assert.That(CrossPlatformTestExecutor.IsRunningInTestExecutor.Value).IsTrue(); + } + + [Test] + public async Task Test_HooksAlsoExecuteInCustomExecutor() + { + // Verify that the hooks executed in the custom executor + await Assert.That(_beforeTestHookExecutedWithCustomExecutor).IsTrue(); + + // After hook will be verified by its own assertions + await Assert.That(Thread.CurrentThread.Name).IsEqualTo("CrossPlatformTestExecutor"); + } +} + +/// +/// Tests demonstrating SetHookExecutor with static hooks +/// +[EngineTest(ExpectedResult.Pass)] +[SetBothExecutors] // This attribute sets both executors +public class SetHookExecutorWithStaticHooksTests +{ + [BeforeEvery(Test)] + public static async Task BeforeEveryTest(TestContext context) + { + // This static hook is GLOBAL and runs for ALL tests in the assembly + // Only run assertions for tests in SetHookExecutorWithStaticHooksTests class + if (context.TestDetails.ClassType == typeof(SetHookExecutorWithStaticHooksTests)) + { + // This static hook should execute with the custom executor when CustomHookExecutor is set + await Assert.That(Thread.CurrentThread.Name).IsEqualTo("CrossPlatformTestExecutor"); + await Assert.That(CrossPlatformTestExecutor.IsRunningInTestExecutor.Value).IsTrue(); + context.ObjectBag["BeforeEveryExecuted"] = true; + } + } + + [AfterEvery(Test)] + public static async Task AfterEveryTest(TestContext context) + { + // This static hook is GLOBAL and runs for ALL tests in the assembly + // Only run assertions for tests in SetHookExecutorWithStaticHooksTests class + if (context.TestDetails.ClassType == typeof(SetHookExecutorWithStaticHooksTests)) + { + // This static hook should execute with the custom executor when CustomHookExecutor is set + await Assert.That(Thread.CurrentThread.Name).IsEqualTo("CrossPlatformTestExecutor"); + await Assert.That(CrossPlatformTestExecutor.IsRunningInTestExecutor.Value).IsTrue(); + } + } + + [Test] + public async Task Test_StaticHooksExecuteInCustomExecutor() + { + // Verify the BeforeEvery hook ran + await Assert.That(TestContext.Current?.ObjectBag["BeforeEveryExecuted"]).IsEquatableOrEqualTo(true); + + // Test itself runs in custom executor + await Assert.That(Thread.CurrentThread.Name).IsEqualTo("CrossPlatformTestExecutor"); + } +}