using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Reflection; using System.Threading; using System.Threading.Tasks; using Avalonia.Metadata; using Avalonia.Reactive; using Avalonia.Threading; namespace Avalonia.Headless; /// /// Headless unit test session that needs to be used by the actual testing framework. /// All UI tests are supposed to be executed from one of the methods to keep execution flow on the UI thread. /// Disposing unit test session stops internal dispatcher loop. /// [Unstable("This API is experimental and might be unstable. Use on your risk. API might or might not be changed in a minor update.")] public sealed class HeadlessUnitTestSession : IDisposable { private static readonly Dictionary s_session = new(); private readonly AppBuilder _appBuilder; private readonly CancellationTokenSource _cancellationTokenSource; private readonly BlockingCollection _queue; private readonly Task _dispatchTask; internal const DynamicallyAccessedMemberTypes DynamicallyAccessed = DynamicallyAccessedMemberTypes.PublicMethods | DynamicallyAccessedMemberTypes.NonPublicMethods | DynamicallyAccessedMemberTypes.PublicParameterlessConstructor; private HeadlessUnitTestSession(AppBuilder appBuilder, CancellationTokenSource cancellationTokenSource, BlockingCollection queue, Task dispatchTask) { _appBuilder = appBuilder; _cancellationTokenSource = cancellationTokenSource; _queue = queue; _dispatchTask = dispatchTask; } /// 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); } /// /// 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) { 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, true); using var localCts = cancellationToken.Register(s => ((CancellationTokenSource)s!).Cancel(), cts, true); try { using var application = EnsureApplication(); var task = action(); task.ContinueWith((_, s) => ((CancellationTokenSource)s!).Cancel(), cts, TaskScheduler.FromCurrentSynchronizationContext()); if (cts.IsCancellationRequested) { tcs.TrySetCanceled(cts.Token); return; } var frame = new DispatcherFrame(); using var innerCts = cts.Token.Register(() => frame.Continue = false, true); Dispatcher.UIThread.PushFrame(frame); var result = task.GetAwaiter().GetResult(); tcs.TrySetResult(result); } catch (Exception ex) { tcs.TrySetException(ex); } }); 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(); _queue.CompleteAdding(); _dispatchTask.Wait(); _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)] Type entryPointType) { var tcs = new TaskCompletionSource(); var cancellationTokenSource = new CancellationTokenSource(); var queue = new BlockingCollection(); Task? task = null; task = Task.Run(() => { try { var appBuilder = AppBuilder.Configure(entryPointType); // If windowing subsystem wasn't initialized by user, force headless with default parameters. if (appBuilder.WindowingSubsystemName != "Headless") { appBuilder = appBuilder.UseHeadless(new AvaloniaHeadlessPlatformOptions()); } // ReSharper disable once AccessToModifiedClosure tcs.SetResult(new HeadlessUnitTestSession(appBuilder, cancellationTokenSource, queue, task!)); } catch (Exception e) { tcs.SetException(e); return; } 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. /// [UnconditionalSuppressMessage("Trimming", "IL2072", Justification = "AvaloniaTestApplicationAttribute attribute should preserve type information.")] public static HeadlessUnitTestSession GetOrStartForAssembly(Assembly? assembly) { assembly ??= typeof(HeadlessUnitTestSession).Assembly; lock (s_session) { if (!s_session.TryGetValue(assembly, out var session)) { var appBuilderEntryPointType = assembly.GetCustomAttribute() ?.AppBuilderEntryPointType; session = appBuilderEntryPointType is not null ? StartNew(appBuilderEntryPointType) : StartNew(typeof(Application)); s_session.Add(assembly, session); } return session; } } }