fix: apply CultureAttribute and STAThreadExecutorAttribute to hooks (#5452)#5463
fix: apply CultureAttribute and STAThreadExecutorAttribute to hooks (#5452)#5463
Conversation
Up to standards ✅🟢 Issues
|
| Metric | Results |
|---|---|
| Complexity | 1 |
TIP This summary will be updated as you push new changes. Give us feedback
There was a problem hiding this comment.
Review
Good work on fixing the core issue — CultureAttribute, STAThreadExecutorAttribute, and TestExecutorAttribute now correctly propagate their executor to lifecycle hooks, and the precedence fix (hook's own explicit executor wins over CustomHookExecutor) is logically sound. The PR description is thorough and the commit is well-structured.
Critical bug: ReferenceEquals vs DefaultHookExecutor in reflection mode
This is the main concern. The precedence logic in BeforeTestHookMethod, AfterTestHookMethod, and HookTimeoutHelper checks:
if (ReferenceEquals(executor, DefaultExecutor.Instance) && context.CustomHookExecutor != null)In source-gen mode this works, because generated code initialises HookExecutor = DefaultExecutor.Instance. But in reflection mode, ReflectionHookDiscoveryService.GetHookExecutor() returns new DefaultHookExecutor() — a completely separate class (internal class DefaultHookExecutor : IHookExecutor at line 1006 of ReflectionHookDiscoveryService.cs), not DefaultExecutor.Instance. The ReferenceEquals check therefore always returns false for reflection-discovered hooks, which means CustomHookExecutor (set via SetHookExecutor() — the #2666 scenario) is now completely ignored in reflection mode.
This affects the dual-mode requirement from the project rules. The fix could be to either:
- Have
ReflectionHookDiscoveryService.GetHookExecutor()returnDefaultExecutor.Instancewhen there is no explicit[HookExecutor<T>]attribute on the hook method (instead ofnew DefaultHookExecutor()), or - Change the precedence check to be type-based (
executor is DefaultExecutoror anIDefaultExecutormarker interface) rather than a singleton reference comparison.
Option 1 seems simpler and more consistent — DefaultHookExecutor in the reflection discovery service duplicates DefaultExecutor anyway (both just call action() for everything).
Minor: _afterClassCulture is captured but never asserted
CultureHookTests_ClassLevel._afterClassCulture is set in AfterClass() but there is no test method that asserts it equals "de-AT". The After(Class) path is therefore untested by this PR. Worth adding a Test_AfterClassHook_RanInClassCulture test symmetrical to the Before(Class) one.
Minor: InstanceHookMethod path in HookDelegateBuilder is inconsistent
CreateInstanceHookDelegateAsync still does if (context.CustomHookExecutor != null) without the ReferenceEquals guard (lines 565–586 of HookDelegateBuilder.cs). This means an explicit [HookExecutor<T>] on an instance Before/After test hook can be overridden by CustomHookExecutor — the opposite precedence from what BeforeTestHookMethod/AfterTestHookMethod now implement. Aligning this path with the same guard would make the behaviour consistent across both static and instance hooks.
Minor: lazy Executor property is not thread-safe
The private CultureExecutor? _executor; private CultureExecutor Executor => _executor ??= new CultureExecutor(cultureInfo); pattern (and the equivalent in STAThreadExecutorAttribute / TestExecutorAttribute) has a benign data race: two threads could both see _executor == null and create two instances. For the executors mentioned here (stateless, creating a fresh thread per call) this is harmless in practice. A comment acknowledging this (or a LazyInitializer.EnsureInitialized if you ever have a stateful executor) would prevent future confusion. The existing comments go some way to explaining the safety guarantee, so this is low priority.
Overall the design is clean and the approach of routing everything through IHookRegisteredEventReceiver is the right abstraction. The reflection-mode DefaultHookExecutor inconsistency is the only thing that needs addressing before merge.
There was a problem hiding this comment.
Follow-up review
Both issues flagged as critical in the previous review have been addressed.
Previously raised issues — status
Critical: ReferenceEquals vs DefaultHookExecutor in reflection mode — FIXED
ReflectionHookDiscoveryService.GetHookExecutor() now returns DefaultExecutor.Instance instead of new DefaultHookExecutor(), and the duplicate DefaultHookExecutor class has been removed. The ReferenceEquals(_hookExecutor, DefaultExecutor.Instance) guard in ResolveEffectiveExecutor now works correctly in both source-gen and reflection modes. Good cleanup.
Minor: InstanceHookMethod / HookDelegateBuilder inconsistency — FIXED
HookDelegateBuilder.cs no longer references CustomHookExecutor at all; the executor routing is now entirely owned by ResolveEffectiveExecutor.
Minor: _afterClassCulture is captured but never asserted — STILL MISSING
_afterClassCulture is set in AfterClass() but there is still no Test_AfterClassHook_RanInClassCulture test to assert it. The After(Class) code path therefore remains untested by this PR. Worth adding for symmetry with the Before(Class) coverage.
Minor: lazy Executor property thread-safety — acknowledged, not addressed (low priority)
The ??= pattern is still present; harmless for the current stateless executors, but a comment noting the benign-race assumption would future-proof the code.
New observations
HookMethod.ResolveEffectiveExecutor — clean design
Consolidating the "which executor wins" logic into a single method with an explicit comment about the precedence rule (DefaultExecutor → fallback to CustomHookExecutor) makes the semantics clear and the rule easy to change. The required init + internal SetHookExecutor duality on HookMethod.HookExecutor is a reasonable way to keep the source-gen interface stable while still allowing post-construction mutation from the engine.
TestExecutorAttribute refactoring — AOT safe
The refactoring from a primary constructor to a regular constructor correctly propagates [DynamicallyAccessedMembers(PublicConstructors)] to both the field and the constructor parameter, and Activator.CreateInstance(_type) is still covered by the annotation. No AOT regression.
CultureHookTests_MethodLevelOverride.BeforeTest comment
The test comment accurately describes the current semantics (method-level [Culture] overrides class-level for Before(Test) too), but this is an implicit coupling: BeforeTest will only ever be validated against fr-FR because the class has exactly one test. If a second test without [Culture("fr-FR")] were added, BeforeTest would fail. This is fine for a test fixture but worth a quick note in the comment to prevent confusion.
Summary
The critical issue from the previous round is fixed, the HookDelegateBuilder inconsistency is also resolved, and the overall design is sound. The only remaining item worth acting on before merge is adding the Test_AfterClassHook_RanInClassCulture assertion to verify the After(Class) hook also picks up the class-level culture.
There was a problem hiding this comment.
Third-round review (commit c182a7e)
All previously flagged issues have been addressed. Here's a status summary plus fresh eyes on the final state.
Issues from prior rounds — resolved
| Issue | Status |
|---|---|
ReferenceEquals broken in reflection mode (DefaultHookExecutor instead of DefaultExecutor.Instance) |
Fixed — GetHookExecutor() now returns the singleton and the duplicate class is gone |
InstanceHookMethod / HookDelegateBuilder inconsistent executor precedence |
Fixed — routed through ResolveEffectiveExecutor, both static and instance hooks share one rule |
After(Class) culture path untested |
Fixed — AfterClass() now asserts CurrentCulture.Name == "de-AT" directly instead of capturing to a never-asserted field |
Single-test invariant undocumented in CultureHookTests_MethodLevelOverride |
Fixed — comment added |
Lazy Executor property thread-safety (??= benign race) |
Acknowledged; still present but harmless for stateless executors. No action needed |
Final-state observations
HookMethod.ResolveEffectiveExecutor is the right design. A single method with an explicit comment about precedence (DefaultExecutor.Instance → fallback to CustomHookExecutor) is easy to reason about and easy to change. The required init + internal SetHookExecutor duality is consistent with the pre-existing Timeout mutation pattern in the same record.
TestExecutorAttribute (non-generic) AOT annotation. Moving the ctor parameter's [DynamicallyAccessedMembers] to a backing field and preserving it on the ctor parameter keeps the trimmer annotation chain intact. No regression.
_isHookExecutor caches IsAssignableFrom. Caching the interface check on construction rather than calling it per invocation is a small but correct optimisation.
Test coverage is now complete for the stated scenarios. Before(Class), Before(Test), After(Test), After(Class), method-level override, and class-level inheritance are all exercised. The After(Class) path correctly asserts inline since no subsequent test can read a captured value.
Verdict
Code is clean, the bug is correctly fixed, precedence semantics are well-defined, and the dual-mode requirement is satisfied. Ready to merge.
There was a problem hiding this comment.
Fourth-round review (commit 4e35d71 → current)
All issues flagged across the three prior rounds are fully resolved. This is a final-state review.
Issues from all prior rounds — status
| Issue | Round raised | Status |
|---|---|---|
ReferenceEquals vs new DefaultHookExecutor() in reflection mode |
Round 1 (critical) | Fixed — GetHookExecutor() now returns DefaultExecutor.Instance |
_afterClassCulture captured but never asserted |
Round 1 | Fixed — AfterClass() asserts inline now |
HookDelegateBuilder still had its own CustomHookExecutor branch |
Round 2 | Fixed — deleted, all paths go through ResolveEffectiveExecutor |
??= lazy executor field not thread-safe |
Round 1 | Acknowledged as benign (stateless executors) — no action needed |
Architecture quality
HookMethod.ResolveEffectiveExecutor is exactly the right place for precedence logic. Moving it into the hook model rather than scattering the check across BeforeTestHookMethod, AfterTestHookMethod, InstanceHookMethod, and HookTimeoutHelper eliminates four independent (and previously inconsistent) enforcement points. The ReferenceEquals(DefaultExecutor.Instance) sentinel pattern is clean and correct for both source-gen and reflection modes since the reflection path was fixed to return the same singleton.
The deletion of DefaultHookExecutor (a duplicate of DefaultExecutor) and HookTimeoutHelper.ExecuteHookWithPotentialCustomExecutor is good hygiene — the removed code was already superseded by the centralized approach.
Test coverage
The three-class test structure in CultureHookTests.cs is well-designed:
CultureHookTests_ClassLevel: coversBefore(Class),After(Class),Before(Test),After(Test), and the test body all running under the class-level culture.CultureHookTests_MethodLevelOverride: a single test with[Culture("fr-FR")]inside a[Culture("de-AT")]class verifies that the most-specific executor wins and thatBefore(Test)sees the method-level culture. The comment explaining the single-test invariant is good — it prevents future contributors from naively adding tests to the class and breaking the assertion.CultureHookTests_MethodLevelInheritsClass: the no-override case, keeping inheritance clean.
No new concerns
The public API snapshots are updated consistently across all four target frameworks. The HookRegisteredContext.HookExecutor { get; set; } addition follows the existing Timeout property pattern exactly — no API design concern there.
Ready to merge. The bug is correctly fixed, the precedence semantics are well-defined and centralized, dual-mode correctness is verified (source-gen and reflection both use DefaultExecutor.Instance as the sentinel), and the regression tests cover the failure modes. No blocking issues.
…5452) CultureAttribute, STAThreadExecutorAttribute and TestExecutorAttribute were only wired into the test executor pipeline. Lifecycle hooks (Before/After Test, Class, Assembly, Session) ran on the default executor regardless of scope, so a class-level [Culture] never affected its hooks. Fix: - Implement IHookRegisteredEventReceiver on all three attributes so class/assembly/session hooks get the custom executor. - Also call SetHookExecutor in OnTestRegistered so per-test hooks share the same executor as the test body. - Add settable HookExecutor on HookRegisteredContext, applied back to the hook method by EventReceiverOrchestrator (mirrors the Timeout pattern). - Cache one executor instance per attribute so the test and hook executor roles share the same object instead of allocating twice. Also fixes a precedence bug surfaced by the change: BeforeTestHookMethod, AfterTestHookMethod and HookTimeoutHelper preferred TestContext.CustomHookExecutor over the hook's own HookExecutor unconditionally. A hook with an explicit [HookExecutor<T>] now wins; CustomHookExecutor is only a fallback when the hook is still on DefaultExecutor. This preserves the #2666 SetHookExecutor scenario without overriding explicit method-level declarations. Follow-up #5462 tracks the inverse gap: HookExecutorAttribute at class or assembly level is still ignored by discovery.
…mode sentinel Consolidates the duplicated "hook's own HookExecutor wins over CustomHookExecutor" precedence logic into HookMethod.ResolveEffectiveExecutor, collapsing BeforeTestHookMethod / AfterTestHookMethod / HookTimeoutHelper to single-line executor calls. Also fixes a pre-existing reflection-mode regression: the precedence check uses ReferenceEquals(_, DefaultExecutor.Instance), but ReflectionHookDiscoveryService was returning a fresh DefaultHookExecutor per hook, breaking the #2666 SetHookExecutor scenario under --reflection. Reusing DefaultExecutor.Instance restores parity with source-gen.
…culture Instance hooks went through a separate execution path in HookDelegateBuilder.CreateInstanceHookDelegateAsync that unconditionally preferred CustomHookExecutor, letting it silently override an explicit [HookExecutor<T>] on an instance before/after hook (the opposite of the static path's precedence). Route InstanceHookMethod.ExecuteAsync through ResolveEffectiveExecutor and collapse the delegate builder's custom-executor branch so both static and instance hooks share one precedence rule — and, as a bonus, instance hooks now honour Timeout when a custom hook executor is in use. Also assert CultureInfo.CurrentCulture inside CultureHookTests_ClassLevel's After(Class) hook so the After(Class) path is actually covered (no subsequent test can read a field captured after the class tears down).
The BeforeTest assertion only holds because this fixture contains exactly one
test with [Culture("fr-FR")]. Adding a test without the override would cause
BeforeTest to fail against de-AT. Cross-reference the inherits-class fixture
to keep the two cases obvious.
Reflects new OnHookRegistered members on CultureAttribute, STAThreadExecutorAttribute, and TestExecutorAttribute, plus the settable HookExecutor on HookRegisteredContext introduced in #5452.
fa69e16 to
0b6ad1b
Compare
There was a problem hiding this comment.
Fifth-round review (final state: commit 0b6ad1be)
The latest commit is a pure public API snapshot update for all four target frameworks (net8.0, net9.0, net10.0, net4.7), correctly reflecting the additions from this PR:
HookRegisteredContext.HookExecutor { get; set; }OnHookRegistered(HookRegisteredContext)onCultureAttribute,STAThreadExecutorAttribute,TestExecutorAttribute, andTestExecutorAttribute<T>- The additional
IHookRegisteredEventReceiverinterface in the attribute class signatures
All four snapshot files are updated consistently. No concerns.
Status of all prior issues
| Issue | Status |
|---|---|
ReferenceEquals vs new DefaultHookExecutor() in reflection mode |
Fixed |
_afterClassCulture captured but never asserted |
Fixed |
HookDelegateBuilder inconsistent executor precedence |
Fixed |
Single-test invariant undocumented in CultureHookTests_MethodLevelOverride |
Fixed |
Lazy Executor ??= benign thread race |
Acknowledged as harmless, no action needed |
No new concerns introduced by this commit. The PR is clean and ready to merge.
- docs: escape literal {AssemblyName} in command-line-flags so Docusaurus
MDX stops parsing it as an undefined JSX expression (Test Deploy to
GitHub Pages was failing on main since db13cfc).
- snapshots: regenerate TUnit.Core public API snapshots to include the
InconclusiveTestException(string) ctor added in e1a1ab1 but never
committed to the .verified.txt files (RunPublicAPITestsModule was
failing on all modularpipeline runners since that commit).
There was a problem hiding this comment.
Sixth-round review (no new commits since round 5)
The last commit on this PR is still 0b6ad1be (public API snapshot update), which was fully reviewed in round 5. There are no new changes to evaluate.
Status of all tracked issues
| Issue | Round raised | Status |
|---|---|---|
ReferenceEquals vs new DefaultHookExecutor() in reflection mode |
Round 1 (critical) | Fixed |
_afterClassCulture captured but never asserted |
Round 1 | Fixed |
HookDelegateBuilder inconsistent executor precedence |
Round 2 | Fixed |
Single-test invariant undocumented in CultureHookTests_MethodLevelOverride |
Round 2 | Fixed |
Lazy Executor ??= benign thread race |
Round 1 | Acknowledged as harmless — no action needed |
No new commits, no new concerns. The PR is clean and all previously raised issues are resolved. Ready to merge.
There was a problem hiding this comment.
Sixth-round review (no new commits since round 5)
The last commit on this PR is still 0b6ad1be (public API snapshot update), which was fully reviewed in round 5. There are no new changes to evaluate.
Status of all tracked issues
| Issue | Round raised | Status |
|---|---|---|
| ReferenceEquals vs new DefaultHookExecutor() in reflection mode | Round 1 (critical) | Fixed |
| _afterClassCulture captured but never asserted | Round 1 | Fixed |
| HookDelegateBuilder inconsistent executor precedence | Round 2 | Fixed |
| Single-test invariant undocumented in CultureHookTests_MethodLevelOverride | Round 2 | Fixed |
| Lazy Executor ??= benign thread race | Round 1 | Acknowledged as harmless, no action needed |
No new commits, no new concerns. The PR is clean and all previously raised issues are resolved. Ready to merge.
There was a problem hiding this comment.
Sixth-round review (no new commits since round 5): The last commit on this PR is still 0b6ad1b (public API snapshot update), fully reviewed in round 5. No new changes to evaluate. All previously raised issues are resolved. Ready to merge.
… executor
Instance Before/After(Test) hooks had their HookExecutor baked in at
hook-registration time from class-level scoped attributes (e.g.
[Culture("de-AT")] via OnHookRegistered). This meant method-level
overrides (e.g. [Culture("fr-FR")] on the test) were ignored by hooks,
even though OnTestRegistered correctly resolved the most-specific
scoped attribute into CustomHookExecutor.
Track whether HookExecutor was set explicitly via [HookExecutor<T>]
(at init time) vs implicitly via OnHookRegistered. Explicit executors
still win; otherwise, per-test CustomHookExecutor takes precedence,
falling back to the registration-time executor.
Fixes CultureHookTests_MethodLevelOverride where Before(Test) ran
under class-level de-AT instead of method-level fr-FR.
There was a problem hiding this comment.
Review
This is a well-executed bug fix for a real user pain point (class-level [Culture] / [STAThread] being ignored by hooks). I've done an independent review of the full diff. Previous review rounds have already caught and resolved the significant issues, so I'll focus on confirming correctness and flagging anything remaining.
What the PR does well
Precedence logic is correct. ResolveEffectiveExecutor in HookMethod establishes a clean three-tier priority:
- Explicit
[HookExecutor<T>]on the hook method (_hookExecutorIsExplicit) — always wins TestContext.CustomHookExecutor(set per-test viaOnTestRegistered, scope-filtered) — most-specific for the test_hookExecutorset viaOnHookRegistered— class/assembly fallback
This correctly handles the method-level override case documented in CultureHookTests_MethodLevelOverride.
Executor caching is correct. The ??= lazy initialization in CultureAttribute, STAThreadExecutorAttribute, and TestExecutorAttribute<T> ensures at most one executor instance is created per attribute instance. The benign double-initialization race under parallel registration was acknowledged in prior reviews and is genuinely harmless since all races produce functionally identical executor instances.
DefaultExecutor.Instance sentinel comparison is correct. The reflection path now returns DefaultExecutor.Instance instead of new DefaultHookExecutor(), making the ReferenceEquals check in _hookExecutorIsExplicit reliable across both source-gen and reflection modes. This is a critical correctness fix.
HookMethod is a record — private fields copy correctly. The _hookExecutor and _hookExecutorIsExplicit private fields will be included in the compiler-generated copy constructor, so with expressions on hook records correctly propagate the explicit flag.
Class/assembly/session hooks use HookExecutor (not ResolveEffectiveExecutor). This is correct: those hooks have no TestContext to pass, and their executor is set during hook registration by OnHookRegistered via SetHookExecutor. BeforeClassHookMethod, AfterClassHookMethod, BeforeAssemblyHookMethod, AfterAssemblyHookMethod, BeforeTestSessionHookMethod, and AfterTestSessionHookMethod all call HookExecutor.Execute*, which reads _hookExecutor — the value written by SetHookExecutor. This path is correct.
HookTimeoutHelper simplification is clean. Removing ExecuteHookWithPotentialCustomExecutor and the type-switch on BeforeTestHookMethod/AfterTestHookMethod/InstanceHookMethod eliminates duplicated executor-resolution logic. The hook's own ExecuteAsync now owns the resolution.
DefaultHookExecutor removal is clean. It was a local-only no-op implementation. DefaultExecutor.Instance (via GenericAbstractExecutor) serves the same role as a singleton, eliminating an allocation per hook registration.
One minor observation
TestExecutorAttribute (the non-generic variant) calls Activator.CreateInstance(_type) in Executor, which is a reflection-based instantiation. The [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] annotation on _type correctly propagates the AOT hint from the constructor parameter, so trimming will preserve the required constructors. No issue here.
Test coverage
The three new CultureHookTests_* classes cover the main scenarios:
- Class-level
[Culture]flowing toBefore(Class),After(Class),Before(Test),After(Test), and the test body - Method-level
[Culture]overriding the class-level forBefore(Test)(with the single-test constraint documented inline) - Inheritance of class-level culture when no method override is present
SetHookExecutorTests and SetHookExecutorWithStaticHooksTests remain intact and verify the #2666 fallback path continues to work.
Summary
The implementation is correct, AOT-safe, dual-mode compliant, and the test coverage is adequate. All issues from prior review rounds have been addressed. This is ready to merge.
Updated [TUnit](https://github.com/thomhurst/TUnit) from 1.29.0 to 1.30.0. <details> <summary>Release notes</summary> _Sourced from [TUnit's releases](https://github.com/thomhurst/TUnit/releases)._ ## 1.30.0 <!-- Release notes generated using configuration in .github/release.yml at v1.30.0 --> ## What's Changed ### Other Changes * perf: eliminate locks from mock invocation and verification hot paths by @thomhurst in thomhurst/TUnit#5422 * feat: TUnit0074 analyzer for redundant hook attributes on overrides by @thomhurst in thomhurst/TUnit#5459 * fix(mocks): respect generic type argument accessibility (#5453) by @thomhurst in thomhurst/TUnit#5460 * fix(mocks): skip inaccessible internal accessors when mocking Azure.Response by @thomhurst in thomhurst/TUnit#5461 * fix: apply CultureAttribute and STAThreadExecutorAttribute to hooks (#5452) by @thomhurst in thomhurst/TUnit#5463 ### Dependencies * chore(deps): update tunit to 1.29.0 by @thomhurst in thomhurst/TUnit#5446 * chore(deps): update react to ^19.2.5 by @thomhurst in thomhurst/TUnit#5457 * chore(deps): update opentelemetry to 1.15.2 by @thomhurst in thomhurst/TUnit#5456 * chore(deps): update dependency qs to v6.15.1 by @thomhurst in thomhurst/TUnit#5458 **Full Changelog**: thomhurst/TUnit@v1.29.0...v1.30.0 Commits viewable in [compare view](thomhurst/TUnit@v1.29.0...v1.30.0). </details> [](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) --- <details> <summary>Dependabot commands and options</summary> <br /> You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show <dependency name> ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) </details> Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Summary
Fixes #5452.
CultureAttribute,STAThreadExecutorAttribute, andTestExecutorAttributewere only wired into the test-executor pipeline, so lifecycle hooks (Before/AfterforTest,Class,Assembly,Session) ignored the attribute entirely — a class-level[Culture("de-AT")]never influenced its own hooks.Changes
Core infrastructure
HookRegisteredContextgains a settableHookExecutorproperty — the hook-side equivalent of the existingTimeoutpattern.HookMethod.HookExecutorkeepsrequired init(so generated code can still assign via object initializer) plus aninternal SetHookExecutorfor engine mutation after construction.EventReceiverOrchestrator.InvokeHookRegistrationEventReceiversAsyncapplies the override back to the hook method after the receiver loop.Attribute fix
CultureAttribute,STAThreadExecutorAttribute,TestExecutorAttribute<T>,TestExecutorAttribute(Type)all now implementIHookRegisteredEventReceiver. Class/assembly/session hooks pick up the executor throughOnHookRegistered; per-test hooks pick it up via aSetHookExecutorcall inOnTestRegistered.Precedence bug uncovered by the change
Before this PR,
BeforeTestHookMethod,AfterTestHookMethod, andHookTimeoutHelperall checkedTestContext.CustomHookExecutorbefore the hook's ownHookExecutor. OnceCultureAttributestarted callingSetHookExecutor, a class-level[Culture]would silently override an unrelated global[BeforeEvery(Test)]hook's own[HookExecutor<T>]attribute. Fixed so the hook's explicit executor wins;CustomHookExecutoris only used as a fallback when the hook is still onDefaultExecutor. This preserves the #2666SetHookExecutorscenario for hooks that didn't declare their own executor.Out-of-scope follow-up
#5462 tracks the inverse gap:
HookExecutorAttributeplaced at class or assembly level is still ignored by both discovery paths. Left for a separate change.Test plan
CultureHookTests_ClassLevel,CultureHookTests_MethodLevelOverride,CultureHookTests_MethodLevelInheritsClass— cover class-level culture flowing toBefore(Test)/After(Test)/Before(Class)and method-level override winning.CultureTests,HookExecutorTests,SetHookExecutorTests,SetHookExecutorWithStaticHooksTests, new culture/hook tests) — 25/25 pass.CultureHookTests_ClassLevelpasses.