HeadlessUnitTestSession.cs 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222
  1. using System;
  2. using System.Collections.Concurrent;
  3. using System.Collections.Generic;
  4. using System.Diagnostics.CodeAnalysis;
  5. using System.Reflection;
  6. using System.Threading;
  7. using System.Threading.Tasks;
  8. using Avalonia.Metadata;
  9. using Avalonia.Reactive;
  10. using Avalonia.Threading;
  11. namespace Avalonia.Headless;
  12. /// <summary>
  13. /// Headless unit test session that needs to be used by the actual testing framework.
  14. /// All UI tests are supposed to be executed from one of the <see cref="Dispatch"/> methods to keep execution flow on the UI thread.
  15. /// Disposing unit test session stops internal dispatcher loop.
  16. /// </summary>
  17. [Unstable("This API is experimental and might be unstable. Use on your risk. API might or might not be changed in a minor update.")]
  18. public sealed class HeadlessUnitTestSession : IDisposable
  19. {
  20. private static readonly Dictionary<Assembly, HeadlessUnitTestSession> s_session = new();
  21. private readonly AppBuilder _appBuilder;
  22. private readonly CancellationTokenSource _cancellationTokenSource;
  23. private readonly BlockingCollection<Action> _queue;
  24. private readonly Task _dispatchTask;
  25. internal const DynamicallyAccessedMemberTypes DynamicallyAccessed =
  26. DynamicallyAccessedMemberTypes.PublicMethods |
  27. DynamicallyAccessedMemberTypes.NonPublicMethods |
  28. DynamicallyAccessedMemberTypes.PublicParameterlessConstructor;
  29. private HeadlessUnitTestSession(AppBuilder appBuilder, CancellationTokenSource cancellationTokenSource,
  30. BlockingCollection<Action> queue, Task dispatchTask)
  31. {
  32. _appBuilder = appBuilder;
  33. _cancellationTokenSource = cancellationTokenSource;
  34. _queue = queue;
  35. _dispatchTask = dispatchTask;
  36. }
  37. /// <inheritdoc cref="Dispatch{TResult}(Func{Task{TResult}}, CancellationToken)"/>
  38. public Task Dispatch(Action action, CancellationToken cancellationToken)
  39. {
  40. return Dispatch(() =>
  41. {
  42. action();
  43. return Task.FromResult(0);
  44. }, cancellationToken);
  45. }
  46. /// <inheritdoc cref="Dispatch{TResult}(Func{Task{TResult}}, CancellationToken)"/>
  47. public Task<TResult> Dispatch<TResult>(Func<TResult> action, CancellationToken cancellationToken)
  48. {
  49. return Dispatch(() => Task.FromResult(action()), cancellationToken);
  50. }
  51. /// <summary>
  52. /// Dispatch method queues an async operation on the dispatcher thread, creates a new application instance,
  53. /// setting app avalonia services, and runs <paramref name="action"/> parameter.
  54. /// </summary>
  55. /// <param name="action">Action to execute on the dispatcher thread with avalonia services.</param>
  56. /// <param name="cancellationToken">Cancellation token to cancel execution.</param>
  57. /// <exception cref="ObjectDisposedException">
  58. /// If global session was already cancelled and thread killed, it's not possible to dispatch any actions again
  59. /// </exception>
  60. public Task<TResult> Dispatch<TResult>(Func<Task<TResult>> action, CancellationToken cancellationToken)
  61. {
  62. if (_cancellationTokenSource.IsCancellationRequested)
  63. {
  64. throw new ObjectDisposedException("Session was already disposed.");
  65. }
  66. var token = _cancellationTokenSource.Token;
  67. var tcs = new TaskCompletionSource<TResult>();
  68. _queue.Add(() =>
  69. {
  70. var cts = new CancellationTokenSource();
  71. using var globalCts = token.Register(s => ((CancellationTokenSource)s!).Cancel(), cts, true);
  72. using var localCts = cancellationToken.Register(s => ((CancellationTokenSource)s!).Cancel(), cts, true);
  73. try
  74. {
  75. using var application = EnsureApplication();
  76. var task = action();
  77. task.ContinueWith((_, s) => ((CancellationTokenSource)s!).Cancel(), cts,
  78. TaskScheduler.FromCurrentSynchronizationContext());
  79. if (cts.IsCancellationRequested)
  80. {
  81. tcs.TrySetCanceled(cts.Token);
  82. return;
  83. }
  84. var frame = new DispatcherFrame();
  85. using var innerCts = cts.Token.Register(() => frame.Continue = false, true);
  86. Dispatcher.UIThread.PushFrame(frame);
  87. var result = task.GetAwaiter().GetResult();
  88. tcs.TrySetResult(result);
  89. }
  90. catch (Exception ex)
  91. {
  92. tcs.TrySetException(ex);
  93. }
  94. });
  95. return tcs.Task;
  96. }
  97. private IDisposable EnsureApplication()
  98. {
  99. var scope = AvaloniaLocator.EnterScope();
  100. try
  101. {
  102. Dispatcher.ResetForUnitTests();
  103. _appBuilder.SetupUnsafe();
  104. }
  105. catch
  106. {
  107. scope.Dispose();
  108. throw;
  109. }
  110. return Disposable.Create(() =>
  111. {
  112. scope.Dispose();
  113. Dispatcher.ResetForUnitTests();
  114. });
  115. }
  116. public void Dispose()
  117. {
  118. _cancellationTokenSource.Cancel();
  119. _queue.CompleteAdding();
  120. _dispatchTask.Wait();
  121. _cancellationTokenSource.Dispose();
  122. }
  123. /// <summary>
  124. /// Creates instance of <see cref="HeadlessUnitTestSession"/>.
  125. /// </summary>
  126. /// <param name="entryPointType">
  127. /// Parameter from which <see cref="AppBuilder"/> should be created.
  128. /// It either needs to have BuildAvaloniaApp -> AppBuilder method or inherit Application.
  129. /// </param>
  130. public static HeadlessUnitTestSession StartNew(
  131. [DynamicallyAccessedMembers(DynamicallyAccessed)]
  132. Type entryPointType)
  133. {
  134. var tcs = new TaskCompletionSource<HeadlessUnitTestSession>();
  135. var cancellationTokenSource = new CancellationTokenSource();
  136. var queue = new BlockingCollection<Action>();
  137. Task? task = null;
  138. task = Task.Run(() =>
  139. {
  140. try
  141. {
  142. var appBuilder = AppBuilder.Configure(entryPointType);
  143. // If windowing subsystem wasn't initialized by user, force headless with default parameters.
  144. if (appBuilder.WindowingSubsystemName != "Headless")
  145. {
  146. appBuilder = appBuilder.UseHeadless(new AvaloniaHeadlessPlatformOptions());
  147. }
  148. // ReSharper disable once AccessToModifiedClosure
  149. tcs.SetResult(new HeadlessUnitTestSession(appBuilder, cancellationTokenSource, queue, task!));
  150. }
  151. catch (Exception e)
  152. {
  153. tcs.SetException(e);
  154. return;
  155. }
  156. while (!cancellationTokenSource.IsCancellationRequested)
  157. {
  158. try
  159. {
  160. var action = queue.Take(cancellationTokenSource.Token);
  161. action();
  162. }
  163. catch (OperationCanceledException)
  164. {
  165. }
  166. }
  167. });
  168. return tcs.Task.GetAwaiter().GetResult();
  169. }
  170. /// <summary>
  171. /// Creates a session from AvaloniaTestApplicationAttribute attribute or reuses any existing.
  172. /// If AvaloniaTestApplicationAttribute doesn't exist, empty application is used.
  173. /// </summary>
  174. [UnconditionalSuppressMessage("Trimming", "IL2072",
  175. Justification = "AvaloniaTestApplicationAttribute attribute should preserve type information.")]
  176. public static HeadlessUnitTestSession GetOrStartForAssembly(Assembly? assembly)
  177. {
  178. assembly ??= typeof(HeadlessUnitTestSession).Assembly;
  179. lock (s_session)
  180. {
  181. if (!s_session.TryGetValue(assembly, out var session))
  182. {
  183. var appBuilderEntryPointType = assembly.GetCustomAttribute<AvaloniaTestApplicationAttribute>()
  184. ?.AppBuilderEntryPointType;
  185. session = appBuilderEntryPointType is not null ?
  186. StartNew(appBuilderEntryPointType) :
  187. StartNew(typeof(Application));
  188. s_session.Add(assembly, session);
  189. }
  190. return session;
  191. }
  192. }
  193. }