diff --git a/src/Avalonia.Controls/AppBuilder.cs b/src/Avalonia.Controls/AppBuilder.cs index 9af50180dd..77cc9d4dcb 100644 --- a/src/Avalonia.Controls/AppBuilder.cs +++ b/src/Avalonia.Controls/AppBuilder.cs @@ -288,17 +288,26 @@ namespace Avalonia } s_setupWasAlreadyCalled = true; + SetupUnsafe(); + } + + /// + /// Setup method that doesn't check for input initalizers being set. + /// Nor + /// + internal void SetupUnsafe() + { _optionsInitializers?.Invoke(); - RuntimePlatformServicesInitializer(); - RenderingSubsystemInitializer(); - WindowingSubsystemInitializer(); - AfterPlatformServicesSetupCallback(Self); - Instance = _appFactory(); + RuntimePlatformServicesInitializer?.Invoke(); + RenderingSubsystemInitializer?.Invoke(); + WindowingSubsystemInitializer?.Invoke(); + AfterPlatformServicesSetupCallback?.Invoke(Self); + Instance = _appFactory!(); Instance.ApplicationLifetime = _lifetime; AvaloniaLocator.CurrentMutable.BindToSelf(Instance); Instance.RegisterServices(); Instance.Initialize(); - AfterSetupCallback(Self); + AfterSetupCallback?.Invoke(Self); Instance.OnFrameworkInitializationCompleted(); } } diff --git a/src/Headless/Avalonia.Headless.NUnit/AvaloniaTestMethodCommand.cs b/src/Headless/Avalonia.Headless.NUnit/AvaloniaTestMethodCommand.cs index 318005f0b0..91428388b6 100644 --- a/src/Headless/Avalonia.Headless.NUnit/AvaloniaTestMethodCommand.cs +++ b/src/Headless/Avalonia.Headless.NUnit/AvaloniaTestMethodCommand.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Reflection; using System.Threading.Tasks; using Avalonia.Threading; @@ -11,6 +12,8 @@ namespace Avalonia.Headless.NUnit; internal class AvaloniaTestMethodCommand : DelegatingTestCommand { private readonly HeadlessUnitTestSession _session; + private readonly List _beforeTest; + private readonly List _afterTest; private static FieldInfo s_innerCommand = typeof(DelegatingTestCommand) .GetField("innerCommand", BindingFlags.Instance | BindingFlags.NonPublic)!; @@ -19,24 +22,35 @@ internal class AvaloniaTestMethodCommand : DelegatingTestCommand private static FieldInfo s_afterTest = typeof(BeforeAndAfterTestCommand) .GetField("AfterTest", BindingFlags.Instance | BindingFlags.NonPublic)!; - private AvaloniaTestMethodCommand(HeadlessUnitTestSession session, TestCommand innerCommand) + private AvaloniaTestMethodCommand( + HeadlessUnitTestSession session, + TestCommand innerCommand, + List beforeTest, + List afterTest) : base(innerCommand) { _session = session; + _beforeTest = beforeTest; + _afterTest = afterTest; } public static TestCommand ProcessCommand(HeadlessUnitTestSession session, TestCommand command) + { + 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) { if (s_beforeTest.GetValue(beforeAndAfterTestCommand) is Action beforeTest) { - Action beforeAction = c => session.Dispatch(() => beforeTest(c), default); + Action beforeAction = c => before.Add(() => beforeTest(c)); s_beforeTest.SetValue(beforeAndAfterTestCommand, beforeAction); } if (s_afterTest.GetValue(beforeAndAfterTestCommand) is Action afterTest) { - Action afterAction = c => session.Dispatch(() => afterTest(c), default); + Action afterAction = c => after.Add(() => afterTest(c)); s_afterTest.SetValue(beforeAndAfterTestCommand, afterAction); } } @@ -44,11 +58,11 @@ internal class AvaloniaTestMethodCommand : DelegatingTestCommand if (command is DelegatingTestCommand delegatingTestCommand && s_innerCommand.GetValue(delegatingTestCommand) is TestCommand inner) { - s_innerCommand.SetValue(delegatingTestCommand, ProcessCommand(session, inner)); + s_innerCommand.SetValue(delegatingTestCommand, ProcessCommand(session, inner, before, after)); } else if (command is TestMethodCommand methodCommand) { - return new AvaloniaTestMethodCommand(session, methodCommand); + return new AvaloniaTestMethodCommand(session, methodCommand, before, after); } return command; @@ -62,6 +76,8 @@ internal class AvaloniaTestMethodCommand : DelegatingTestCommand // Unfortunately, NUnit has issues with custom synchronization contexts, which means we need to add some hacks to make it work. private async Task ExecuteTestMethod(TestExecutionContext context) { + _beforeTest.ForEach(a => a()); + var testMethod = innerCommand.Test.Method; var methodInfo = testMethod!.MethodInfo; @@ -81,6 +97,11 @@ internal class AvaloniaTestMethodCommand : DelegatingTestCommand if (context.CurrentResult.AssertionResults.Count > 0) context.CurrentResult.RecordTestCompletion(); + if (context.ExecutionStatus != TestExecutionStatus.AbortRequested) + { + _afterTest.ForEach(a => a()); + } + return context.CurrentResult; } } diff --git a/src/Headless/Avalonia.Headless/HeadlessUnitTestSession.cs b/src/Headless/Avalonia.Headless/HeadlessUnitTestSession.cs index b44d530442..eb47201400 100644 --- a/src/Headless/Avalonia.Headless/HeadlessUnitTestSession.cs +++ b/src/Headless/Avalonia.Headless/HeadlessUnitTestSession.cs @@ -1,10 +1,14 @@ using System; using System.Collections.Concurrent; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Reflection; using System.Runtime.ExceptionServices; using System.Threading; using System.Threading.Tasks; +using Avalonia.Controls.Platform; +using Avalonia.Reactive; +using Avalonia.Rendering; using Avalonia.Threading; namespace Avalonia.Headless; @@ -15,14 +19,12 @@ namespace Avalonia.Headless; /// to keep execution flow on the UI thread. /// Disposing unit test session stops internal dispatcher loop. /// -/// -/// As Avalonia supports only a single Application instance created, this session must be created only once as well. -/// public sealed class HeadlessUnitTestSession : IDisposable { + private static readonly ConcurrentDictionary s_session = new(); + + private readonly AppBuilder _appBuilder; private readonly CancellationTokenSource _cancellationTokenSource; - private static HeadlessUnitTestSession? s_session; - private static object s_lock = new(); private readonly BlockingCollection _queue; private readonly Task _dispatchTask; @@ -30,27 +32,41 @@ public sealed class HeadlessUnitTestSession : IDisposable DynamicallyAccessedMemberTypes.PublicMethods | DynamicallyAccessedMemberTypes.NonPublicMethods | DynamicallyAccessedMemberTypes.PublicParameterlessConstructor; - - private HeadlessUnitTestSession(Type entryPointType, CancellationTokenSource cancellationTokenSource, BlockingCollection queue, Task _dispatchTask) + + private HeadlessUnitTestSession(AppBuilder appBuilder, CancellationTokenSource cancellationTokenSource, + BlockingCollection queue, Task dispatchTask) { + _appBuilder = appBuilder; _cancellationTokenSource = cancellationTokenSource; _queue = queue; - this._dispatchTask = _dispatchTask; - EntryPointType = entryPointType; + _dispatchTask = dispatchTask; } - internal Type EntryPointType { get; } - + /// public Task Dispatch(Action action, CancellationToken cancellationToken) { - return Dispatch(() => { action(); return Task.FromResult(0); }, cancellationToken); + return Dispatch(() => + { + action(); + return Task.FromResult(0); + }, cancellationToken); } - + + /// public Task Dispatch(Func action, CancellationToken cancellationToken) { return Dispatch(() => Task.FromResult(action()), cancellationToken); } + /// + /// Dispatch method queues an async operation on the dispatcher thread, creates a new application instance, + /// setting app avalonia services, and runs parameter. + /// + /// Action to execute on the dispatcher thread with avalonia services. + /// 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) { if (_cancellationTokenSource.IsCancellationRequested) @@ -63,6 +79,8 @@ public sealed class HeadlessUnitTestSession : IDisposable var tcs = new TaskCompletionSource(); _queue.Add(() => { + using var application = EnsureApplication(); + var cts = new CancellationTokenSource(); using var globalCts = token.Register(s => ((CancellationTokenSource)s!).Cancel(), cts, true); using var localCts = cancellationToken.Register(s => ((CancellationTokenSource)s!).Cancel(), cts, true); @@ -92,7 +110,28 @@ public sealed class HeadlessUnitTestSession : IDisposable }); return tcs.Task; } - + + private IDisposable EnsureApplication() + { + var scope = AvaloniaLocator.EnterScope(); + try + { + Dispatcher.ResetForUnitTests(); + _appBuilder.SetupUnsafe(); + } + catch + { + scope.Dispose(); + throw; + } + + return Disposable.Create(() => + { + scope.Dispose(); + Dispatcher.ResetForUnitTests(); + }); + } + public void Dispose() { _cancellationTokenSource.Cancel(); @@ -101,19 +140,6 @@ public sealed class HeadlessUnitTestSession : IDisposable _cancellationTokenSource.Dispose(); } - /// - /// Creates instance of . - /// - /// - /// Parameter from which should be created. - /// It either needs to have BuildAvaloniaApp -> AppBuilder method or inherit Application. - /// - public static HeadlessUnitTestSession StartNew< - [DynamicallyAccessedMembers(DynamicallyAccessed)] TEntryPointType>() - { - return StartNew(typeof(TEntryPointType)); - } - /// /// Creates instance of . /// @@ -122,7 +148,8 @@ public sealed class HeadlessUnitTestSession : IDisposable /// It either needs to have BuildAvaloniaApp -> AppBuilder method or inherit Application. /// public static HeadlessUnitTestSession StartNew( - [DynamicallyAccessedMembers(DynamicallyAccessed)] Type entryPointType) + [DynamicallyAccessedMembers(DynamicallyAccessed)] + Type entryPointType) { var tcs = new TaskCompletionSource(); var cancellationTokenSource = new CancellationTokenSource(); @@ -141,15 +168,8 @@ public sealed class HeadlessUnitTestSession : IDisposable appBuilder = appBuilder.UseHeadless(new AvaloniaHeadlessPlatformOptions()); } - appBuilder.SetupWithoutStarting(); - - if (!Dispatcher.UIThread.SupportsRunLoops) - { - throw new InvalidOperationException("Avalonia Headless platform has failed to initialize."); - } - // ReSharper disable once AccessToModifiedClosure - tcs.SetResult(new HeadlessUnitTestSession(entryPointType, cancellationTokenSource, queue, task!)); + tcs.SetResult(new HeadlessUnitTestSession(appBuilder, cancellationTokenSource, queue, task!)); } catch (Exception e) { @@ -166,7 +186,6 @@ public sealed class HeadlessUnitTestSession : IDisposable } catch (OperationCanceledException) { - } } }); @@ -178,34 +197,17 @@ public sealed class HeadlessUnitTestSession : IDisposable /// Creates a session from AvaloniaTestApplicationAttribute attribute or reuses any existing. /// If AvaloniaTestApplicationAttribute doesn't exist, empty application is used. /// - /// - /// Note, only single session can be crated per app execution. - /// - [UnconditionalSuppressMessage("Trimming", "IL2072", Justification = "AvaloniaTestApplicationAttribute attribute should preserve type information.")] + [UnconditionalSuppressMessage("Trimming", "IL2072", + Justification = "AvaloniaTestApplicationAttribute attribute should preserve type information.")] public static HeadlessUnitTestSession GetOrStartForAssembly(Assembly? assembly) { - lock (s_lock) + return s_session.GetOrAdd(assembly ?? typeof(HeadlessUnitTestSession).Assembly, a => { - var appBuilderEntryPointType = assembly?.GetCustomAttribute() + var appBuilderEntryPointType = a.GetCustomAttribute() ?.AppBuilderEntryPointType; - - if (s_session is not null) - { - var hasNoAttribute = appBuilderEntryPointType == null && s_session.EntryPointType == typeof(Application); - if (!hasNoAttribute && appBuilderEntryPointType != s_session.EntryPointType) - { - // Avalonia doesn't support multiple Application instances. At least at the moment. - throw new System.InvalidOperationException( - "AvaloniaTestApplicationAttribute must be defined only once per single unit tests session."); - } - - return s_session; - } - - - s_session = appBuilderEntryPointType is not null ? StartNew(appBuilderEntryPointType) : StartNew(typeof(Application)); - - return s_session; - } + return appBuilderEntryPointType is not null ? + StartNew(appBuilderEntryPointType) : + StartNew(typeof(Application)); + }); } }