diff --git a/src/Headless/Avalonia.Headless.NUnit/AvaloniaTestMethodCommand.cs b/src/Headless/Avalonia.Headless.NUnit/AvaloniaTestMethodCommand.cs index bd3f41de6a..2edc60a1a9 100644 --- a/src/Headless/Avalonia.Headless.NUnit/AvaloniaTestMethodCommand.cs +++ b/src/Headless/Avalonia.Headless.NUnit/AvaloniaTestMethodCommand.cs @@ -29,7 +29,7 @@ internal class AvaloniaTestMethodCommand : TestCommand .GetField("BeforeTest", BindingFlags.Instance | BindingFlags.NonPublic)!; private static FieldInfo s_afterTest = typeof(BeforeAndAfterTestCommand) .GetField("AfterTest", BindingFlags.Instance | BindingFlags.NonPublic)!; - + private AvaloniaTestMethodCommand( HeadlessUnitTestSession session, TestCommand innerCommand, @@ -47,7 +47,7 @@ internal class AvaloniaTestMethodCommand : TestCommand { return ProcessCommand(session, command, new List(), new List()); } - + private static TestCommand ProcessCommand(HeadlessUnitTestSession session, TestCommand command, List before, List after) { if (command is BeforeAndAfterTestCommand beforeAndAfterTestCommand) @@ -79,7 +79,7 @@ internal class AvaloniaTestMethodCommand : TestCommand public override TestResult Execute(TestExecutionContext context) { - return _session.Dispatch(() => ExecuteTestMethod(context), default).GetAwaiter().GetResult(); + return _session.DispatchCore(() => ExecuteTestMethod(context), true, default).GetAwaiter().GetResult(); } // Unfortunately, NUnit has issues with custom synchronization contexts, which means we need to add some hacks to make it work. diff --git a/src/Headless/Avalonia.Headless/Avalonia.Headless.csproj b/src/Headless/Avalonia.Headless/Avalonia.Headless.csproj index fe071b594f..7731b4efc4 100644 --- a/src/Headless/Avalonia.Headless/Avalonia.Headless.csproj +++ b/src/Headless/Avalonia.Headless/Avalonia.Headless.csproj @@ -16,6 +16,7 @@ + diff --git a/src/Headless/Avalonia.Headless/HeadlessUnitTestSession.cs b/src/Headless/Avalonia.Headless/HeadlessUnitTestSession.cs index 91e47d661a..a9bfa6e518 100644 --- a/src/Headless/Avalonia.Headless/HeadlessUnitTestSession.cs +++ b/src/Headless/Avalonia.Headless/HeadlessUnitTestSession.cs @@ -23,7 +23,7 @@ public sealed class HeadlessUnitTestSession : IDisposable private readonly AppBuilder _appBuilder; private readonly CancellationTokenSource _cancellationTokenSource; - private readonly BlockingCollection _queue; + private readonly BlockingCollection<(Action, ExecutionContext?)> _queue; private readonly Task _dispatchTask; internal const DynamicallyAccessedMemberTypes DynamicallyAccessed = @@ -32,7 +32,7 @@ public sealed class HeadlessUnitTestSession : IDisposable DynamicallyAccessedMemberTypes.PublicParameterlessConstructor; private HeadlessUnitTestSession(AppBuilder appBuilder, CancellationTokenSource cancellationTokenSource, - BlockingCollection queue, Task dispatchTask) + BlockingCollection<(Action, ExecutionContext?)> queue, Task dispatchTask) { _appBuilder = appBuilder; _cancellationTokenSource = cancellationTokenSource; @@ -40,20 +40,26 @@ public sealed class HeadlessUnitTestSession : IDisposable _dispatchTask = dispatchTask; } - /// + /// public Task Dispatch(Action action, CancellationToken cancellationToken) { - return Dispatch(() => + return DispatchCore(() => { action(); return Task.FromResult(0); - }, cancellationToken); + }, false ,cancellationToken); } - /// + /// public Task Dispatch(Func action, CancellationToken cancellationToken) { - return Dispatch(() => Task.FromResult(action()), cancellationToken); + return DispatchCore(() => Task.FromResult(action()), false, cancellationToken); + } + + /// + public Task Dispatch(Func> action, CancellationToken cancellationToken) + { + return DispatchCore(action, false, cancellationToken); } /// @@ -61,11 +67,12 @@ public sealed class HeadlessUnitTestSession : IDisposable /// setting app avalonia services, and runs parameter. /// /// Action to execute on the dispatcher thread with avalonia services. + /// Whether dispatch should capture ExecutionContext. /// Cancellation token to cancel execution. /// /// If global session was already cancelled and thread killed, it's not possible to dispatch any actions again /// - public Task Dispatch(Func> action, CancellationToken cancellationToken) + internal Task DispatchCore(Func> action, bool captureExecutionContext, CancellationToken cancellationToken) { if (_cancellationTokenSource.IsCancellationRequested) { @@ -73,9 +80,10 @@ public sealed class HeadlessUnitTestSession : IDisposable } var token = _cancellationTokenSource.Token; + var executionContext = captureExecutionContext ? ExecutionContext.Capture() : null; var tcs = new TaskCompletionSource(); - _queue.Add(() => + _queue.Add((() => { var cts = new CancellationTokenSource(); using var globalCts = token.Register(s => ((CancellationTokenSource)s!).Cancel(), cts, true); @@ -84,7 +92,6 @@ public sealed class HeadlessUnitTestSession : IDisposable try { using var application = EnsureApplication(); - var task = action(); task.ContinueWith((_, s) => ((CancellationTokenSource)s!).Cancel(), cts, TaskScheduler.FromCurrentSynchronizationContext()); @@ -106,7 +113,7 @@ public sealed class HeadlessUnitTestSession : IDisposable { tcs.TrySetException(ex); } - }); + }, executionContext)); return tcs.Task; } @@ -152,7 +159,7 @@ public sealed class HeadlessUnitTestSession : IDisposable { var tcs = new TaskCompletionSource(); var cancellationTokenSource = new CancellationTokenSource(); - var queue = new BlockingCollection(); + var queue = new BlockingCollection<(Action, ExecutionContext?)>(); Task? task = null; task = Task.Run(() => @@ -180,8 +187,15 @@ public sealed class HeadlessUnitTestSession : IDisposable { try { - var action = queue.Take(cancellationTokenSource.Token); - action(); + var (action, executionContext) = queue.Take(cancellationTokenSource.Token); + if (executionContext is not null) + { + ExecutionContext.Run(executionContext, a => ((Action)a!).Invoke(), action); + } + else + { + action(); + } } catch (OperationCanceledException) { diff --git a/tests/Avalonia.Headless.UnitTests/ThreadingTests.cs b/tests/Avalonia.Headless.UnitTests/ThreadingTests.cs index 5a6026c1de..9d5ef6e500 100644 --- a/tests/Avalonia.Headless.UnitTests/ThreadingTests.cs +++ b/tests/Avalonia.Headless.UnitTests/ThreadingTests.cs @@ -1,4 +1,6 @@ using System; +using System.Linq; +using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using Avalonia.Threading; @@ -14,6 +16,7 @@ public class ThreadingTests #endif public void Should_Be_On_Dispatcher_Thread() { + ValidateTestContext(); Dispatcher.UIThread.VerifyAccess(); } @@ -34,20 +37,40 @@ public class ThreadingTests #endif public async Task DispatcherTimer_Works_On_The_Same_Thread(int interval) { + Assert.NotNull(SynchronizationContext.Current); + ValidateTestContext(); + var currentThread = Thread.CurrentThread; + await Task.Delay(100); - var currentThread = Thread.CurrentThread; + ValidateTestContext(); + Assert.True(currentThread == Thread.CurrentThread); + var tcs = new TaskCompletionSource(); - var hasCompleted = false; DispatcherTimer.RunOnce(() => { - hasCompleted = currentThread == Thread.CurrentThread; - - tcs.SetResult(); + try + { + ValidateTestContext(); + Assert.True(currentThread == Thread.CurrentThread); + tcs.SetResult(); + } + catch (Exception ex) + { + tcs.SetException(ex); + } }, TimeSpan.FromTicks(interval)); await tcs.Task; - Assert.True(hasCompleted); + } + + private void ValidateTestContext([CallerMemberName] string runningMethodName = null) + { +#if NUNIT + var testName = TestContext.CurrentContext.Test.Name; + // Test.Name also includes parameters. + Assert.AreEqual(testName.Split('(').First(), runningMethodName); +#endif } }