Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
49 changes: 49 additions & 0 deletions src/TUnit.Core/Contexts/TestRegisteredContext.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using TUnit.Core.Interfaces;

namespace TUnit.Core;
Expand All @@ -8,6 +9,9 @@ namespace TUnit.Core;
/// </summary>
public class TestRegisteredContext
{
private List<ITestRegisteredEventReceiver>? _executorReceivers;
private int _dispatchedExecutorReceiverCount;

public string TestName { get; }
public string? CustomDisplayName { get; }
public TestContext TestContext { get; }
Expand Down Expand Up @@ -37,6 +41,7 @@ public TestRegisteredContext(TestContext testContext)
public void SetTestExecutor(ITestExecutor executor)
{
DiscoveredTest.TestExecutor = executor;
QueueExecutorEventReceiver(executor);
}

/// <summary>
Expand All @@ -46,6 +51,7 @@ public void SetTestExecutor(ITestExecutor executor)
public void SetHookExecutor(IHookExecutor executor)
{
TestContext.CustomHookExecutor = executor;
QueueExecutorEventReceiver(executor);
}

/// <summary>
Expand All @@ -66,4 +72,47 @@ public void SetSkipped(string reason)
TestContext.SkipReason = reason;
TestContext.Metadata.TestDetails.ClassInstance = SkippedTestInstance.Instance;
}

/// <summary>
/// Queues an executor that is itself an <see cref="ITestRegisteredEventReceiver"/> for dispatch.
/// </summary>
/// <param name="executor">The executor a registration receiver has just installed.</param>
/// <remarks>
/// An executor is neither an attribute, an argument nor the class instance, so the engine's
/// eligible-object pass cannot collect it: it comes into existence part-way through the dispatch of the
/// receivers that pass produced. An executor that is both an <see cref="ITestExecutor"/> and an
/// <see cref="IHookExecutor"/> arrives through two calls and is queued once.
/// </remarks>
private void QueueExecutorEventReceiver(object executor)
{
if (executor is not ITestRegisteredEventReceiver receiver)
{
return;
}

_executorReceivers ??= [];

if (!_executorReceivers.Contains(receiver))
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
{
_executorReceivers.Add(receiver);
}
}

/// <summary>
/// Dequeues the next executor awaiting its registration callback.
/// </summary>
/// <param name="receiver">When this method returns, contains the executor to dispatch to.</param>
/// <returns><see langword="true"/> if an executor was awaiting dispatch; otherwise, <see langword="false"/>.</returns>
/// <remarks>Each executor is returned once, however many times it was installed.</remarks>
internal bool TryDequeueExecutorEventReceiver([NotNullWhen(true)] out ITestRegisteredEventReceiver? receiver)
{
if (_executorReceivers is null || _dispatchedExecutorReceiverCount == _executorReceivers.Count)
{
receiver = null;
return false;
}

receiver = _executorReceivers[_dispatchedExecutorReceiverCount++];
return true;
}
}
29 changes: 22 additions & 7 deletions src/TUnit.Engine/Services/TestFilterService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,19 @@ public IReadOnlyCollection<AbstractExecutableTest> FilterTests(ITestExecutionFil
return filteredTests;
}

private async Task InvokeTestRegisteredReceiver(ITestRegisteredEventReceiver receiver, TestRegisteredContext context)
{
try
{
await receiver.OnTestRegistered(context).ConfigureAwait(false);
}
catch (Exception ex)
{
await logger.LogErrorAsync($"Error in test registered event receiver: {ex.Message}").ConfigureAwait(false);
throw;
}
}

private async Task RegisterTest(AbstractExecutableTest test, bool isForExecution)
{
var registeredReceivers = test.Context.GetTestRegisteredReceivers();
Expand All @@ -86,14 +99,16 @@ private async Task RegisterTest(AbstractExecutableTest test, bool isForExecution

foreach (var receiver in registeredReceivers)
{
try
{
await receiver.OnTestRegistered(registeredContext).ConfigureAwait(false);
}
catch (Exception ex)
await InvokeTestRegisteredReceiver(receiver, registeredContext).ConfigureAwait(false);

// The receiver above may have installed an executor that is an ITestRegisteredEventReceiver
// in its own right, as DedicatedThreadExecutor is. GetTestRegisteredReceivers cannot have
// collected it, because it did not exist when that list was built. Dispatching it here
// rather than after the loop keeps its contribution at the position of the attribute that
// installed it, so a later attribute still overrides it.
while (registeredContext.TryDequeueExecutorEventReceiver(out var executorReceiver))
{
await logger.LogErrorAsync($"Error in test registered event receiver: {ex.Message}").ConfigureAwait(false);
throw;
await InvokeTestRegisteredReceiver(executorReceiver, registeredContext).ConfigureAwait(false);
}
}
}
Expand Down
82 changes: 82 additions & 0 deletions tests/TUnit.TestProject/Bugs/6767/ExecutorEventReceiverTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
using TUnit.Core.Executors;
using TUnit.Core.Interfaces;
using TUnit.TestProject.Attributes;

namespace TUnit.TestProject.Bugs._6767;

/// <summary>
/// Regression test for https://github.com/thomhurst/TUnit/issues/6767
/// An executor that also implements ITestRegisteredEventReceiver must receive
/// OnTestRegistered, so the parallel limit it declares during registration is applied.
/// </summary>
[EngineTest(ExpectedResult.Pass)]
[TestExecutor<SerialExecutor>]
public class ExecutorEventReceiverTests
{
private static int s_concurrent;
private static int s_peak;

[Before(Class)]
public static void Reset()
{
s_concurrent = 0;
s_peak = 0;
}

[Test]
public async Task ExecutorSetsTheLimiter()
{
await Assert.That(TestContext.Current!.Parallelism.Limiter).IsTypeOf<SerialLimit>();
}

[Test, Repeat(9)]
public Task Measure() => MeasureAsync();

[Test, DependsOn(nameof(Measure))]
public async Task PeakIsOne()
{
await Assert.That(s_peak).IsEqualTo(1);
}

private static async Task MeasureAsync()
{
var current = Interlocked.Increment(ref s_concurrent);

int old;
do
{
old = s_peak;
if (current <= old)
{
break;
}
}
while (Interlocked.CompareExchange(ref s_peak, current, old) != old);

await Task.Delay(50).ConfigureAwait(false);

Interlocked.Decrement(ref s_concurrent);
}
}

/// <summary>
/// An executor is installed by an attribute part-way through registration, so it can only
/// declare its parallel limit through its own ITestRegisteredEventReceiver implementation.
/// </summary>
internal sealed class SerialExecutor : GenericAbstractExecutor, ITestRegisteredEventReceiver
{
public int Order => 0;

public ValueTask OnTestRegistered(TestRegisteredContext context)
{
context.SetParallelLimiter(new SerialLimit());
return default;
}

protected override ValueTask ExecuteAsync(Func<ValueTask> action) => action();
}

internal sealed class SerialLimit : IParallelLimit
{
public int Limit => 1;
}
Loading