From bb893b189c3e46b779d7d206eee156d0b584f29a Mon Sep 17 00:00:00 2001 From: Max Katz Date: Fri, 28 Apr 2023 04:58:54 -0400 Subject: [PATCH] Make HeadlessUnitTestSession run individual frames per test --- .../AvaloniaTestMethodCommand.cs | 9 +- .../Avalonia.Headless.NUnit/ExecutionQueue.cs | 71 -------------- .../Avalonia.Headless.XUnit.csproj | 6 -- ...unner.cs => AvaloniaTestAssemblyRunner.cs} | 0 .../AvaloniaTestCase.cs | 12 +-- .../AvaloniaTestCaseRunner.cs | 98 +++++++++++++++++++ .../AvaloniaTheoryTestCase.cs | 8 +- .../HeadlessUnitTestSession.cs | 95 +++++++++++++++--- .../ThreadingTests.cs | 7 ++ 9 files changed, 200 insertions(+), 106 deletions(-) delete mode 100644 src/Headless/Avalonia.Headless.NUnit/ExecutionQueue.cs rename src/Headless/Avalonia.Headless.XUnit/{AvaloniaTestRunner.cs => AvaloniaTestAssemblyRunner.cs} (100%) create mode 100644 src/Headless/Avalonia.Headless.XUnit/AvaloniaTestCaseRunner.cs diff --git a/src/Headless/Avalonia.Headless.NUnit/AvaloniaTestMethodCommand.cs b/src/Headless/Avalonia.Headless.NUnit/AvaloniaTestMethodCommand.cs index a7e461f76e..318005f0b0 100644 --- a/src/Headless/Avalonia.Headless.NUnit/AvaloniaTestMethodCommand.cs +++ b/src/Headless/Avalonia.Headless.NUnit/AvaloniaTestMethodCommand.cs @@ -1,6 +1,7 @@ using System; using System.Reflection; using System.Threading.Tasks; +using Avalonia.Threading; using NUnit.Framework.Interfaces; using NUnit.Framework.Internal; using NUnit.Framework.Internal.Commands; @@ -30,12 +31,12 @@ internal class AvaloniaTestMethodCommand : DelegatingTestCommand { if (s_beforeTest.GetValue(beforeAndAfterTestCommand) is Action beforeTest) { - Action beforeAction = c => session.Dispatcher.Invoke(() => beforeTest(c)); + Action beforeAction = c => session.Dispatch(() => beforeTest(c), default); s_beforeTest.SetValue(beforeAndAfterTestCommand, beforeAction); } if (s_afterTest.GetValue(beforeAndAfterTestCommand) is Action afterTest) { - Action afterAction = c => session.Dispatcher.Invoke(() => afterTest(c)); + Action afterAction = c => session.Dispatch(() => afterTest(c), default); s_afterTest.SetValue(beforeAndAfterTestCommand, afterAction); } } @@ -52,10 +53,10 @@ internal class AvaloniaTestMethodCommand : DelegatingTestCommand return command; } - + public override TestResult Execute(TestExecutionContext context) { - return _session.Dispatcher.InvokeOnQueue(() => ExecuteTestMethod(context)); + return _session.Dispatch(() => ExecuteTestMethod(context), 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.NUnit/ExecutionQueue.cs b/src/Headless/Avalonia.Headless.NUnit/ExecutionQueue.cs deleted file mode 100644 index b42f126389..0000000000 --- a/src/Headless/Avalonia.Headless.NUnit/ExecutionQueue.cs +++ /dev/null @@ -1,71 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using Avalonia.Threading; - -namespace Avalonia.Headless.NUnit; - -// To force all tests (which might run in parallel) to be executed in a queue. -internal static class ExecutionQueue -{ - private static bool s_running; - private static Queue> s_queue = new(); - - private static async void TryExecuteNext() - { - if (s_running || s_queue.Count == 0) return; - try - { - s_running = true; - await s_queue.Dequeue()(); - } - finally - { - s_running = false; - } - TryExecuteNext(); - } - - private static void PostToTheQueue(this Dispatcher dispatcher, Func cb) - { - dispatcher.Post(() => - { - s_queue.Enqueue(cb); - TryExecuteNext(); - }); - } - - internal static Task ExecuteOnQueue(this Dispatcher dispatcher, Func> cb) - { - var tcs = new TaskCompletionSource(); - PostToTheQueue(dispatcher, async () => - { - try - { - var result = await cb(); - tcs.TrySetResult(result); - } - catch (Exception ex) - { - tcs.TrySetException(ex); - } - }); - return tcs.Task; - } - - public static TResult InvokeOnQueue(this Dispatcher dispatcher, Func> cb, CancellationToken cancellationToken = default) - { - return dispatcher - .InvokeAsync(() => ExecuteOnQueue(dispatcher, cb), DispatcherPriority.Normal, cancellationToken) - .GetTask().Unwrap() - .GetAwaiter().GetResult(); - } - - public static Task InvokeOnQueueAsync(this Dispatcher dispatcher, Func> cb, CancellationToken cancellationToken = default) - { - return dispatcher - .InvokeAsync(() => ExecuteOnQueue(dispatcher, cb), DispatcherPriority.Normal, cancellationToken) - .GetTask().Unwrap(); - } -} diff --git a/src/Headless/Avalonia.Headless.XUnit/Avalonia.Headless.XUnit.csproj b/src/Headless/Avalonia.Headless.XUnit/Avalonia.Headless.XUnit.csproj index c244a1a4cd..4ab70eb07d 100644 --- a/src/Headless/Avalonia.Headless.XUnit/Avalonia.Headless.XUnit.csproj +++ b/src/Headless/Avalonia.Headless.XUnit/Avalonia.Headless.XUnit.csproj @@ -13,12 +13,6 @@ - - - ExecutionQueue.cs - - - diff --git a/src/Headless/Avalonia.Headless.XUnit/AvaloniaTestRunner.cs b/src/Headless/Avalonia.Headless.XUnit/AvaloniaTestAssemblyRunner.cs similarity index 100% rename from src/Headless/Avalonia.Headless.XUnit/AvaloniaTestRunner.cs rename to src/Headless/Avalonia.Headless.XUnit/AvaloniaTestAssemblyRunner.cs diff --git a/src/Headless/Avalonia.Headless.XUnit/AvaloniaTestCase.cs b/src/Headless/Avalonia.Headless.XUnit/AvaloniaTestCase.cs index b6c0cd11c4..092662745c 100644 --- a/src/Headless/Avalonia.Headless.XUnit/AvaloniaTestCase.cs +++ b/src/Headless/Avalonia.Headless.XUnit/AvaloniaTestCase.cs @@ -1,8 +1,8 @@ using System; using System.ComponentModel; +using System.Runtime.ExceptionServices; using System.Threading; using System.Threading.Tasks; -using Avalonia.Headless.NUnit; using Avalonia.Threading; using Xunit.Abstractions; using Xunit.Sdk; @@ -37,12 +37,10 @@ internal class AvaloniaTestCase : XunitTestCase // We need to block the XUnit thread to ensure its concurrency throttle is effective. // See https://github.com/AArnott/Xunit.StaFact/pull/55#issuecomment-826187354 for details. - var runSummary = session.Dispatcher.InvokeOnQueue(async () => - { - var runner = new XunitTestCaseRunner(this, DisplayName, SkipReason, constructorArguments, - TestMethodArguments, messageBus, aggregator, cancellationTokenSource); - return await runner.RunAsync(); - }, cancellationTokenSource.Token); + var runSummary = AvaloniaTestCaseRunner + .RunTest(session, this, DisplayName, SkipReason, constructorArguments, + TestMethodArguments, messageBus, aggregator, cancellationTokenSource) + .GetAwaiter().GetResult(); return Task.FromResult(runSummary); } diff --git a/src/Headless/Avalonia.Headless.XUnit/AvaloniaTestCaseRunner.cs b/src/Headless/Avalonia.Headless.XUnit/AvaloniaTestCaseRunner.cs new file mode 100644 index 0000000000..97fcfa2521 --- /dev/null +++ b/src/Headless/Avalonia.Headless.XUnit/AvaloniaTestCaseRunner.cs @@ -0,0 +1,98 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Avalonia.Threading; +using Xunit.Abstractions; +using Xunit.Sdk; + +namespace Avalonia.Headless.XUnit; + +internal class AvaloniaTestCaseRunner : XunitTestCaseRunner +{ + private readonly Action? _onAfterTestInvoked; + + public AvaloniaTestCaseRunner( + Action? onAfterTestInvoked, + IXunitTestCase testCase, string displayName, string skipReason, object[] constructorArguments, + object[] testMethodArguments, IMessageBus messageBus, ExceptionAggregator aggregator, + CancellationTokenSource cancellationTokenSource) : base(testCase, displayName, skipReason, constructorArguments, + testMethodArguments, messageBus, aggregator, cancellationTokenSource) + { + _onAfterTestInvoked = onAfterTestInvoked; + } + + public static Task RunTest(HeadlessUnitTestSession session, + IXunitTestCase testCase, string displayName, string skipReason, object[] constructorArguments, + object[] testMethodArguments, IMessageBus messageBus, ExceptionAggregator aggregator, + CancellationTokenSource cancellationTokenSource) + { + var afterTest = () => Dispatcher.UIThread.RunJobs(); + return session.Dispatch(async () => + { + var runner = new AvaloniaTestCaseRunner(afterTest, testCase, displayName, + skipReason, constructorArguments, testMethodArguments, messageBus, aggregator, cancellationTokenSource); + return await runner.RunAsync(); + }, cancellationTokenSource.Token); + } + + protected override XunitTestRunner CreateTestRunner(ITest test, IMessageBus messageBus, Type testClass, + object[] constructorArguments, + MethodInfo testMethod, object[] testMethodArguments, string skipReason, + IReadOnlyList beforeAfterAttributes, + ExceptionAggregator aggregator, CancellationTokenSource cancellationTokenSource) + { + return new AvaloniaTestRunner(_onAfterTestInvoked, test, messageBus, testClass, constructorArguments, + testMethod, testMethodArguments, skipReason, beforeAfterAttributes, aggregator, cancellationTokenSource); + } + + private class AvaloniaTestRunner : XunitTestRunner + { + private readonly Action? _onAfterTestInvoked; + + public AvaloniaTestRunner( + Action? onAfterTestInvoked, + ITest test, IMessageBus messageBus, Type testClass, object[] constructorArguments, MethodInfo testMethod, + object[] testMethodArguments, string skipReason, + IReadOnlyList beforeAfterAttributes, ExceptionAggregator aggregator, + CancellationTokenSource cancellationTokenSource) : base(test, messageBus, testClass, constructorArguments, + testMethod, testMethodArguments, skipReason, beforeAfterAttributes, aggregator, cancellationTokenSource) + { + _onAfterTestInvoked = onAfterTestInvoked; + } + + protected override Task InvokeTestMethodAsync(ExceptionAggregator aggregator) + { + return new AvaloniaTestInvoker(_onAfterTestInvoked, Test, MessageBus, TestClass, ConstructorArguments, + TestMethod, TestMethodArguments, BeforeAfterAttributes, aggregator, CancellationTokenSource).RunAsync(); + } + } + + private class AvaloniaTestInvoker : XunitTestInvoker + { + private readonly Action? _onAfterTestInvoked; + + public AvaloniaTestInvoker( + Action? onAfterTestInvoked, + ITest test, IMessageBus messageBus, Type testClass, object[] constructorArguments, MethodInfo testMethod, + object[] testMethodArguments, IReadOnlyList beforeAfterAttributes, + ExceptionAggregator aggregator, CancellationTokenSource cancellationTokenSource) : base(test, messageBus, + testClass, constructorArguments, testMethod, testMethodArguments, beforeAfterAttributes, aggregator, + cancellationTokenSource) + { + _onAfterTestInvoked = onAfterTestInvoked; + } + + protected override async Task AfterTestMethodInvokedAsync() + { + await base.AfterTestMethodInvokedAsync(); + + // Only here we can execute random code after the test, where exception will be properly handled by the XUnit. + if (_onAfterTestInvoked is not null) + { + Aggregator.Run(_onAfterTestInvoked); + } + } + } +} diff --git a/src/Headless/Avalonia.Headless.XUnit/AvaloniaTheoryTestCase.cs b/src/Headless/Avalonia.Headless.XUnit/AvaloniaTheoryTestCase.cs index 4b56de6e6c..ea7e7abee4 100644 --- a/src/Headless/Avalonia.Headless.XUnit/AvaloniaTheoryTestCase.cs +++ b/src/Headless/Avalonia.Headless.XUnit/AvaloniaTheoryTestCase.cs @@ -2,7 +2,6 @@ using System.ComponentModel; using System.Threading; using System.Threading.Tasks; -using Avalonia.Headless.NUnit; using Xunit.Abstractions; using Xunit.Sdk; @@ -25,11 +24,8 @@ internal class AvaloniaTheoryTestCase : XunitTheoryTestCase { var session = HeadlessUnitTestSession.GetOrStartForAssembly(Method.ToRuntimeMethod().DeclaringType?.Assembly); - return session.Dispatcher.InvokeOnQueueAsync(async () => - { - var runner = new XunitTestCaseRunner(this, DisplayName, SkipReason, constructorArguments, + return AvaloniaTestCaseRunner + .RunTest(session, this, DisplayName, SkipReason, constructorArguments, TestMethodArguments, messageBus, aggregator, cancellationTokenSource); - return await runner.RunAsync(); - }, cancellationTokenSource.Token); } } diff --git a/src/Headless/Avalonia.Headless/HeadlessUnitTestSession.cs b/src/Headless/Avalonia.Headless/HeadlessUnitTestSession.cs index f98240d6d7..81295f7fc3 100644 --- a/src/Headless/Avalonia.Headless/HeadlessUnitTestSession.cs +++ b/src/Headless/Avalonia.Headless/HeadlessUnitTestSession.cs @@ -1,6 +1,8 @@ using System; +using System.Collections.Concurrent; using System.Diagnostics.CodeAnalysis; using System.Reflection; +using System.Runtime.ExceptionServices; using System.Threading; using System.Threading.Tasks; using Avalonia.Threading; @@ -18,9 +20,11 @@ namespace Avalonia.Headless; /// public sealed class HeadlessUnitTestSession : IDisposable { - private readonly CancellationTokenSource _cancellationToken; + private readonly CancellationTokenSource _cancellationTokenSource; private static HeadlessUnitTestSession? s_session; private static object s_lock = new(); + private readonly BlockingCollection _queue; + private readonly Task _dispatchTask; internal const DynamicallyAccessedMemberTypes DynamicallyAccessed = DynamicallyAccessedMemberTypes.PublicMethods | @@ -29,9 +33,11 @@ public sealed class HeadlessUnitTestSession : IDisposable private HeadlessUnitTestSession(Type entryPointType, Application application, SynchronizationContext synchronizationContext, - Dispatcher dispatcher, CancellationTokenSource cancellationToken) + Dispatcher dispatcher, CancellationTokenSource cancellationTokenSource, BlockingCollection queue, Task _dispatchTask) { - _cancellationToken = cancellationToken; + _cancellationTokenSource = cancellationTokenSource; + _queue = queue; + this._dispatchTask = _dispatchTask; EntryPointType = entryPointType; Dispatcher = dispatcher; Application = application; @@ -43,9 +49,63 @@ public sealed class HeadlessUnitTestSession : IDisposable public Dispatcher Dispatcher { get; } internal Type EntryPointType { get; } + public Task Dispatch(Action action, CancellationToken cancellationToken) + { + return Dispatch(() => { action(); return Task.FromResult(0); }, cancellationToken); + } + + public Task Dispatch(Func action, CancellationToken cancellationToken) + { + return Dispatch(() => Task.FromResult(action()), cancellationToken); + } + + public Task Dispatch(Func> action, CancellationToken cancellationToken) + { + if (_cancellationTokenSource.IsCancellationRequested) + { + throw new ObjectDisposedException("Session was already disposed."); + } + + var token = _cancellationTokenSource.Token; + + var tcs = new TaskCompletionSource(); + _queue.Add(() => + { + var cts = new CancellationTokenSource(); + using var globalCts = token.Register(s => ((CancellationTokenSource)s!).Cancel(), cts); + using var localCts = cancellationToken.Register(s => ((CancellationTokenSource)s!).Cancel(), cts); + + try + { + var task = action(); + task.ContinueWith((_, s) => ((CancellationTokenSource)s!).Cancel(), cts); + + if (cts.IsCancellationRequested) + { + return; + } + + var frame = new DispatcherFrame(); + using var innerCts = cts.Token.Register(() => frame.Continue = false); + Dispatcher.PushFrame(frame); + + var result = task.GetAwaiter().GetResult(); + tcs.TrySetResult(result); + } + catch (Exception ex) + { + tcs.TrySetException(ex); + } + }); + return tcs.Task; + } + public void Dispose() { - _cancellationToken.Cancel(); + _cancellationTokenSource.Cancel(); + _queue.CompleteAdding(); + _dispatchTask.Wait(); + _cancellationTokenSource.Dispose(); } /// @@ -73,9 +133,10 @@ public sealed class HeadlessUnitTestSession : IDisposable { var tcs = new TaskCompletionSource(); var cancellationTokenSource = new CancellationTokenSource(); + var queue = new BlockingCollection(); - Thread? thread = null; - thread = new Thread(() => + Task? task = null; + task = Task.Run(() => { try { @@ -93,10 +154,10 @@ public sealed class HeadlessUnitTestSession : IDisposable { throw new InvalidOperationException("Avalonia Headless platform has failed to initialize."); } - + // ReSharper disable once AccessToModifiedClosure tcs.SetResult(new HeadlessUnitTestSession(entryPointType, Application.Current!, - SynchronizationContext.Current!, Dispatcher.UIThread, cancellationTokenSource)); + SynchronizationContext.Current!, Dispatcher.UIThread, cancellationTokenSource, queue, task!)); } catch (Exception e) { @@ -104,13 +165,23 @@ public sealed class HeadlessUnitTestSession : IDisposable return; } - Dispatcher.UIThread.MainLoop(cancellationTokenSource.Token); - }) { IsBackground = true }; - thread.Start(); + while (!cancellationTokenSource.IsCancellationRequested) + { + try + { + var action = queue.Take(cancellationTokenSource.Token); + action(); + } + catch (OperationCanceledException) + { + + } + } + }); return tcs.Task.GetAwaiter().GetResult(); } - + /// /// Creates a session from AvaloniaTestApplicationAttribute attribute or reuses any existing. /// If AvaloniaTestApplicationAttribute doesn't exist, empty application is used. diff --git a/tests/Avalonia.Headless.UnitTests/ThreadingTests.cs b/tests/Avalonia.Headless.UnitTests/ThreadingTests.cs index e26b902eb1..b6ba7de8c5 100644 --- a/tests/Avalonia.Headless.UnitTests/ThreadingTests.cs +++ b/tests/Avalonia.Headless.UnitTests/ThreadingTests.cs @@ -13,6 +13,13 @@ public class ThreadingTests Dispatcher.UIThread.VerifyAccess(); } + // This test should always fail, uncomment to test if it fails. + // [AvaloniaFact] + // public void Should_Fail_Test_On_Delayed_Post_When_FlushDispatcher() + // { + // Dispatcher.UIThread.Post(() => throw new InvalidOperationException(), DispatcherPriority.Default); + // } + [AvaloniaTheory] [InlineData(1)] [InlineData(10)]