From f6f2422c578cf0daebcdf48e419f8976818683f1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Mar 2026 15:05:29 +0000 Subject: [PATCH 1/3] Initial plan From 61582a2a688e2e44842be956c5797715d7732afe Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Mar 2026 15:13:58 +0000 Subject: [PATCH 2/3] Fix BeforeEvery/AfterEvery hooks for Class and Assembly not being executed The HookExecutor was missing invocation of BeforeEvery(Class), AfterEvery(Class), BeforeEvery(Assembly), and AfterEvery(Assembly) hooks. While the Test-level hooks had the correct pattern of executing both BeforeEvery and Before hooks, the same pattern was missing for Class and Assembly levels. Updated ExecuteBeforeClassHooksAsync, ExecuteAfterClassHooksAsync, ExecuteBeforeAssemblyHooksAsync, and ExecuteAfterAssemblyHooksAsync to also collect and execute the corresponding BeforeEvery/AfterEvery hooks, following the same pattern already used for Test-level hooks. Co-authored-by: thomhurst <30480171+thomhurst@users.noreply.github.com> Agent-Logs-Url: https://github.com/thomhurst/TUnit/sessions/89836764-b81e-472f-9f00-c6e7e61c02ca --- TUnit.Engine/Services/HookExecutor.cs | 230 ++++++++++++++++++-------- 1 file changed, 160 insertions(+), 70 deletions(-) diff --git a/TUnit.Engine/Services/HookExecutor.cs b/TUnit.Engine/Services/HookExecutor.cs index 47911cbe38..e2c3589063 100644 --- a/TUnit.Engine/Services/HookExecutor.cs +++ b/TUnit.Engine/Services/HookExecutor.cs @@ -157,33 +157,61 @@ public async ValueTask ExecuteBeforeAssemblyHooksAsync(Assembly assembly, Cancel } #endif - var hooks = await _hookCollectionService.CollectBeforeAssemblyHooksAsync(assembly).ConfigureAwait(false); + // Execute BeforeEvery(Assembly) hooks first (global hooks run before specific hooks) + var beforeEveryAssemblyHooks = await _hookCollectionService.CollectBeforeEveryAssemblyHooksAsync().ConfigureAwait(false); - if (hooks.Count == 0) + if (beforeEveryAssemblyHooks.Count > 0) { - return; + foreach (var hook in beforeEveryAssemblyHooks) + { + try + { + assemblyContext.RestoreExecutionContext(); + await ExecuteHookWithActivityAsync(hook, assemblyContext, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + if (ex is SkipTestException) + { + throw; + } + + if (ex.InnerException is SkipTestException skipEx) + { + ExceptionDispatchInfo.Capture(skipEx).Throw(); + } + + throw new BeforeAssemblyException($"BeforeEveryAssembly hook failed: {ex.Message}", ex); + } + } } - foreach (var hook in hooks) + // Execute Before(Assembly) hooks after BeforeEvery hooks + var hooks = await _hookCollectionService.CollectBeforeAssemblyHooksAsync(assembly).ConfigureAwait(false); + + if (hooks.Count > 0) { - try + foreach (var hook in hooks) { - assemblyContext.RestoreExecutionContext(); - await ExecuteHookWithActivityAsync(hook, assemblyContext, cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) - { - if (ex is SkipTestException) + try { - throw; + assemblyContext.RestoreExecutionContext(); + await ExecuteHookWithActivityAsync(hook, assemblyContext, cancellationToken).ConfigureAwait(false); } - - if (ex.InnerException is SkipTestException skipEx) + catch (Exception ex) { - ExceptionDispatchInfo.Capture(skipEx).Throw(); - } + if (ex is SkipTestException) + { + throw; + } - throw new BeforeAssemblyException($"BeforeAssembly hook failed: {ex.Message}", ex); + if (ex.InnerException is SkipTestException skipEx) + { + ExceptionDispatchInfo.Capture(skipEx).Throw(); + } + + throw new BeforeAssemblyException($"BeforeAssembly hook failed: {ex.Message}", ex); + } } } } @@ -191,32 +219,49 @@ public async ValueTask ExecuteBeforeAssemblyHooksAsync(Assembly assembly, Cancel public async ValueTask> ExecuteAfterAssemblyHooksAsync(Assembly assembly, CancellationToken cancellationToken) { var afterAssemblyContext = _contextProvider.GetOrCreateAssemblyContext(assembly); - var hooks = await _hookCollectionService.CollectAfterAssemblyHooksAsync(assembly).ConfigureAwait(false); - - if (hooks.Count == 0) - { -#if NET - FinishAssemblyActivity(assembly, hasErrors: false); -#endif - return []; - } // Defer exception list allocation until actually needed List? exceptions = null; - foreach (var hook in hooks) + // Execute After(Assembly) hooks first (specific hooks run before global hooks for cleanup) + var hooks = await _hookCollectionService.CollectAfterAssemblyHooksAsync(assembly).ConfigureAwait(false); + + if (hooks.Count > 0) { - try + foreach (var hook in hooks) { - afterAssemblyContext.RestoreExecutionContext(); - await ExecuteHookWithActivityAsync(hook, afterAssemblyContext, cancellationToken).ConfigureAwait(false); + try + { + afterAssemblyContext.RestoreExecutionContext(); + await ExecuteHookWithActivityAsync(hook, afterAssemblyContext, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + // Collect hook exceptions instead of throwing immediately + // This allows all hooks to run even if some fail + exceptions ??= []; + exceptions.Add(new AfterAssemblyException($"AfterAssembly hook failed: {ex.Message}", ex)); + } } - catch (Exception ex) + } + + // Execute AfterEvery(Assembly) hooks after After hooks (global hooks run last for cleanup) + var afterEveryAssemblyHooks = await _hookCollectionService.CollectAfterEveryAssemblyHooksAsync().ConfigureAwait(false); + + if (afterEveryAssemblyHooks.Count > 0) + { + foreach (var hook in afterEveryAssemblyHooks) { - // Collect hook exceptions instead of throwing immediately - // This allows all hooks to run even if some fail - exceptions ??= []; - exceptions.Add(new AfterAssemblyException($"AfterAssembly hook failed: {ex.Message}", ex)); + try + { + afterAssemblyContext.RestoreExecutionContext(); + await ExecuteHookWithActivityAsync(hook, afterAssemblyContext, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + exceptions ??= []; + exceptions.Add(new AfterAssemblyException($"AfterEveryAssembly hook failed: {ex.Message}", ex)); + } } } @@ -271,33 +316,61 @@ public async ValueTask ExecuteBeforeClassHooksAsync( } #endif - var hooks = await _hookCollectionService.CollectBeforeClassHooksAsync(testClass).ConfigureAwait(false); + // Execute BeforeEvery(Class) hooks first (global hooks run before specific hooks) + var beforeEveryClassHooks = await _hookCollectionService.CollectBeforeEveryClassHooksAsync().ConfigureAwait(false); - if (hooks.Count == 0) + if (beforeEveryClassHooks.Count > 0) { - return; + foreach (var hook in beforeEveryClassHooks) + { + try + { + classContext.RestoreExecutionContext(); + await ExecuteHookWithActivityAsync(hook, classContext, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + if (ex is SkipTestException) + { + throw; + } + + if (ex.InnerException is SkipTestException skipEx) + { + ExceptionDispatchInfo.Capture(skipEx).Throw(); + } + + throw new BeforeClassException($"BeforeEveryClass hook failed: {ex.Message}", ex); + } + } } - foreach (var hook in hooks) + // Execute Before(Class) hooks after BeforeEvery hooks + var hooks = await _hookCollectionService.CollectBeforeClassHooksAsync(testClass).ConfigureAwait(false); + + if (hooks.Count > 0) { - try + foreach (var hook in hooks) { - classContext.RestoreExecutionContext(); - await ExecuteHookWithActivityAsync(hook, classContext, cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) - { - if (ex is SkipTestException) + try { - throw; + classContext.RestoreExecutionContext(); + await ExecuteHookWithActivityAsync(hook, classContext, cancellationToken).ConfigureAwait(false); } - - if (ex.InnerException is SkipTestException skipEx) + catch (Exception ex) { - ExceptionDispatchInfo.Capture(skipEx).Throw(); - } + if (ex is SkipTestException) + { + throw; + } - throw new BeforeClassException($"BeforeClass hook failed: {ex.Message}", ex); + if (ex.InnerException is SkipTestException skipEx) + { + ExceptionDispatchInfo.Capture(skipEx).Throw(); + } + + throw new BeforeClassException($"BeforeClass hook failed: {ex.Message}", ex); + } } } } @@ -307,32 +380,49 @@ public async ValueTask> ExecuteAfterClassHooksAsync( Type testClass, CancellationToken cancellationToken) { var afterClassContext = _contextProvider.GetOrCreateClassContext(testClass); - var hooks = await _hookCollectionService.CollectAfterClassHooksAsync(testClass).ConfigureAwait(false); - - if (hooks.Count == 0) - { -#if NET - FinishClassActivity(testClass, hasErrors: false); -#endif - return []; - } // Defer exception list allocation until actually needed List? exceptions = null; - foreach (var hook in hooks) + // Execute After(Class) hooks first (specific hooks run before global hooks for cleanup) + var hooks = await _hookCollectionService.CollectAfterClassHooksAsync(testClass).ConfigureAwait(false); + + if (hooks.Count > 0) { - try + foreach (var hook in hooks) { - afterClassContext.RestoreExecutionContext(); - await ExecuteHookWithActivityAsync(hook, afterClassContext, cancellationToken).ConfigureAwait(false); + try + { + afterClassContext.RestoreExecutionContext(); + await ExecuteHookWithActivityAsync(hook, afterClassContext, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + // Collect hook exceptions instead of throwing immediately + // This allows all hooks to run even if some fail + exceptions ??= []; + exceptions.Add(new AfterClassException($"AfterClass hook failed: {ex.Message}", ex)); + } } - catch (Exception ex) + } + + // Execute AfterEvery(Class) hooks after After hooks (global hooks run last for cleanup) + var afterEveryClassHooks = await _hookCollectionService.CollectAfterEveryClassHooksAsync().ConfigureAwait(false); + + if (afterEveryClassHooks.Count > 0) + { + foreach (var hook in afterEveryClassHooks) { - // Collect hook exceptions instead of throwing immediately - // This allows all hooks to run even if some fail - exceptions ??= []; - exceptions.Add(new AfterClassException($"AfterClass hook failed: {ex.Message}", ex)); + try + { + afterClassContext.RestoreExecutionContext(); + await ExecuteHookWithActivityAsync(hook, afterClassContext, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + exceptions ??= []; + exceptions.Add(new AfterClassException($"AfterEveryClass hook failed: {ex.Message}", ex)); + } } } From c53868617fbee3f0fefb4e0c665156497db9e3a6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Mar 2026 16:02:52 +0000 Subject: [PATCH 3/3] Add regression tests for BeforeEvery/AfterEvery Class and Assembly hooks Test targets in TUnit.TestProject: - BeforeEveryClassTests: [BeforeEvery(Class)] hook sets StateBag flag, test verifies - BeforeEveryAssemblyTests: [BeforeEvery(Assembly)] hook sets StateBag flag, test verifies - AfterEveryClassTests: [AfterEvery(Class)] hook writes file as proof of execution - AfterEveryAssemblyTests: [AfterEvery(Assembly)] hook writes file as proof of execution Integration test runners in TUnit.Engine.Tests: - BeforeEveryClassHookTests: Runs filtered tests, asserts 1 passed / 0 failed - BeforeEveryAssemblyHookTests: Same pattern for assembly-level hooks - AfterEveryClassHookTests: Validates test passes and hook file was created - AfterEveryAssemblyHookTests: Validates test passes and hook file was created Co-authored-by: thomhurst <30480171+thomhurst@users.noreply.github.com> Agent-Logs-Url: https://github.com/thomhurst/TUnit/sessions/3e2fe83b-5e07-41db-9d58-975349f40a31 --- .../AfterEveryAssemblyHookTests.cs | 22 ++++++++++++++++ .../AfterEveryClassHookTests.cs | 22 ++++++++++++++++ .../BeforeEveryAssemblyHookTests.cs | 20 +++++++++++++++ .../BeforeEveryClassHookTests.cs | 20 +++++++++++++++ .../AfterTests/AfterEveryAssemblyTests.cs | 25 +++++++++++++++++++ .../AfterTests/AfterEveryClassTests.cs | 25 +++++++++++++++++++ .../BeforeTests/BeforeEveryAssemblyTests.cs | 25 +++++++++++++++++++ .../BeforeTests/BeforeEveryClassTests.cs | 22 ++++++++++++++++ 8 files changed, 181 insertions(+) create mode 100644 TUnit.Engine.Tests/AfterEveryAssemblyHookTests.cs create mode 100644 TUnit.Engine.Tests/AfterEveryClassHookTests.cs create mode 100644 TUnit.Engine.Tests/BeforeEveryAssemblyHookTests.cs create mode 100644 TUnit.Engine.Tests/BeforeEveryClassHookTests.cs create mode 100644 TUnit.TestProject/AfterTests/AfterEveryAssemblyTests.cs create mode 100644 TUnit.TestProject/AfterTests/AfterEveryClassTests.cs create mode 100644 TUnit.TestProject/BeforeTests/BeforeEveryAssemblyTests.cs create mode 100644 TUnit.TestProject/BeforeTests/BeforeEveryClassTests.cs diff --git a/TUnit.Engine.Tests/AfterEveryAssemblyHookTests.cs b/TUnit.Engine.Tests/AfterEveryAssemblyHookTests.cs new file mode 100644 index 0000000000..164c7a45c9 --- /dev/null +++ b/TUnit.Engine.Tests/AfterEveryAssemblyHookTests.cs @@ -0,0 +1,22 @@ +using Shouldly; +using TUnit.Engine.Tests.Enums; +using TUnit.Engine.Tests.Extensions; + +namespace TUnit.Engine.Tests; + +public class AfterEveryAssemblyHookTests(TestMode testMode) : InvokableTestBase(testMode) +{ + [Test] + public async Task Test() + { + await RunTestsWithFilter("/*/*/AfterEveryAssemblyTests/*", + [ + result => result.ResultSummary.Outcome.ShouldBe("Completed"), + result => result.ResultSummary.Counters.Total.ShouldBe(1), + result => result.ResultSummary.Counters.Passed.ShouldBe(1), + result => result.ResultSummary.Counters.Failed.ShouldBe(0), + result => result.ResultSummary.Counters.NotExecuted.ShouldBe(0), + _ => FindFile(x => x.Name.Contains("AfterEveryAssemblyTests") && x.Extension == ".txt").AssertExists() + ]); + } +} diff --git a/TUnit.Engine.Tests/AfterEveryClassHookTests.cs b/TUnit.Engine.Tests/AfterEveryClassHookTests.cs new file mode 100644 index 0000000000..19d85d9d84 --- /dev/null +++ b/TUnit.Engine.Tests/AfterEveryClassHookTests.cs @@ -0,0 +1,22 @@ +using Shouldly; +using TUnit.Engine.Tests.Enums; +using TUnit.Engine.Tests.Extensions; + +namespace TUnit.Engine.Tests; + +public class AfterEveryClassHookTests(TestMode testMode) : InvokableTestBase(testMode) +{ + [Test] + public async Task Test() + { + await RunTestsWithFilter("/*/*/AfterEveryClassTests/*", + [ + result => result.ResultSummary.Outcome.ShouldBe("Completed"), + result => result.ResultSummary.Counters.Total.ShouldBe(1), + result => result.ResultSummary.Counters.Passed.ShouldBe(1), + result => result.ResultSummary.Counters.Failed.ShouldBe(0), + result => result.ResultSummary.Counters.NotExecuted.ShouldBe(0), + _ => FindFile(x => x.Name.Contains("AfterEveryClassTests") && x.Extension == ".txt").AssertExists() + ]); + } +} diff --git a/TUnit.Engine.Tests/BeforeEveryAssemblyHookTests.cs b/TUnit.Engine.Tests/BeforeEveryAssemblyHookTests.cs new file mode 100644 index 0000000000..6e29017c55 --- /dev/null +++ b/TUnit.Engine.Tests/BeforeEveryAssemblyHookTests.cs @@ -0,0 +1,20 @@ +using Shouldly; +using TUnit.Engine.Tests.Enums; + +namespace TUnit.Engine.Tests; + +public class BeforeEveryAssemblyHookTests(TestMode testMode) : InvokableTestBase(testMode) +{ + [Test] + public async Task Test() + { + await RunTestsWithFilter("/*/*/BeforeEveryAssemblyTests/*", + [ + result => result.ResultSummary.Outcome.ShouldBe("Completed"), + result => result.ResultSummary.Counters.Total.ShouldBe(1), + result => result.ResultSummary.Counters.Passed.ShouldBe(1), + result => result.ResultSummary.Counters.Failed.ShouldBe(0), + result => result.ResultSummary.Counters.NotExecuted.ShouldBe(0) + ]); + } +} diff --git a/TUnit.Engine.Tests/BeforeEveryClassHookTests.cs b/TUnit.Engine.Tests/BeforeEveryClassHookTests.cs new file mode 100644 index 0000000000..be2ae791c6 --- /dev/null +++ b/TUnit.Engine.Tests/BeforeEveryClassHookTests.cs @@ -0,0 +1,20 @@ +using Shouldly; +using TUnit.Engine.Tests.Enums; + +namespace TUnit.Engine.Tests; + +public class BeforeEveryClassHookTests(TestMode testMode) : InvokableTestBase(testMode) +{ + [Test] + public async Task Test() + { + await RunTestsWithFilter("/*/*/BeforeEveryClassTests/*", + [ + result => result.ResultSummary.Outcome.ShouldBe("Completed"), + result => result.ResultSummary.Counters.Total.ShouldBe(1), + result => result.ResultSummary.Counters.Passed.ShouldBe(1), + result => result.ResultSummary.Counters.Failed.ShouldBe(0), + result => result.ResultSummary.Counters.NotExecuted.ShouldBe(0) + ]); + } +} diff --git a/TUnit.TestProject/AfterTests/AfterEveryAssemblyTests.cs b/TUnit.TestProject/AfterTests/AfterEveryAssemblyTests.cs new file mode 100644 index 0000000000..985eb25ea6 --- /dev/null +++ b/TUnit.TestProject/AfterTests/AfterEveryAssemblyTests.cs @@ -0,0 +1,25 @@ +namespace TUnit.TestProject.AfterTests; + +public class AfterEveryAssemblyHooks +{ + [AfterEvery(Assembly)] + public static async Task AfterEveryAssembly(AssemblyHookContext context) + { + foreach (var test in context.AllTests) + { + if (test.Metadata.TestDetails.TestName == nameof(AfterEveryAssemblyTests.EnsureAfterEveryAssemblyRuns)) + { + await File.WriteAllTextAsync($"AfterEveryAssemblyTests{Guid.NewGuid():N}.txt", "AfterEvery(Assembly) executed"); + } + } + } +} + +public class AfterEveryAssemblyTests +{ + [Test] + public async Task EnsureAfterEveryAssemblyRuns() + { + await Task.CompletedTask; + } +} diff --git a/TUnit.TestProject/AfterTests/AfterEveryClassTests.cs b/TUnit.TestProject/AfterTests/AfterEveryClassTests.cs new file mode 100644 index 0000000000..8791f9161f --- /dev/null +++ b/TUnit.TestProject/AfterTests/AfterEveryClassTests.cs @@ -0,0 +1,25 @@ +namespace TUnit.TestProject.AfterTests; + +public class AfterEveryClassHooks +{ + [AfterEvery(Class)] + public static async Task AfterEveryClass(ClassHookContext context) + { + foreach (var test in context.Tests) + { + if (test.Metadata.TestDetails.TestName == nameof(AfterEveryClassTests.EnsureAfterEveryClassRuns)) + { + await File.WriteAllTextAsync($"AfterEveryClassTests{Guid.NewGuid():N}.txt", "AfterEvery(Class) executed"); + } + } + } +} + +public class AfterEveryClassTests +{ + [Test] + public async Task EnsureAfterEveryClassRuns() + { + await Task.CompletedTask; + } +} diff --git a/TUnit.TestProject/BeforeTests/BeforeEveryAssemblyTests.cs b/TUnit.TestProject/BeforeTests/BeforeEveryAssemblyTests.cs new file mode 100644 index 0000000000..cd41f54933 --- /dev/null +++ b/TUnit.TestProject/BeforeTests/BeforeEveryAssemblyTests.cs @@ -0,0 +1,25 @@ +namespace TUnit.TestProject.BeforeTests; + +public class BeforeEveryAssemblyHooks +{ + [BeforeEvery(Assembly)] + public static void BeforeEveryAssembly(AssemblyHookContext context) + { + foreach (var test in context.AllTests) + { + if (test.Metadata.TestDetails.TestName == nameof(BeforeEveryAssemblyTests.EnsureBeforeEveryAssemblyHit)) + { + test.StateBag.Items["BeforeEveryAssemblyHit"] = true; + } + } + } +} + +public class BeforeEveryAssemblyTests +{ + [Test] + public async Task EnsureBeforeEveryAssemblyHit() + { + await Assert.That(TestContext.Current?.StateBag.Items["BeforeEveryAssemblyHit"]).IsEquatableOrEqualTo(true); + } +} diff --git a/TUnit.TestProject/BeforeTests/BeforeEveryClassTests.cs b/TUnit.TestProject/BeforeTests/BeforeEveryClassTests.cs new file mode 100644 index 0000000000..7429d7e299 --- /dev/null +++ b/TUnit.TestProject/BeforeTests/BeforeEveryClassTests.cs @@ -0,0 +1,22 @@ +namespace TUnit.TestProject.BeforeTests; + +public class BeforeEveryClassHooks +{ + [BeforeEvery(Class)] + public static void BeforeEveryClass(ClassHookContext context) + { + foreach (var test in context.Tests) + { + test.StateBag.Items["BeforeEveryClassHit"] = true; + } + } +} + +public class BeforeEveryClassTests +{ + [Test] + public async Task EnsureBeforeEveryClassHit() + { + await Assert.That(TestContext.Current?.StateBag.Items["BeforeEveryClassHit"]).IsEquatableOrEqualTo(true); + } +}