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;
}
}
}