|
|
|
@ -18,7 +18,6 @@ namespace Avalonia.Headless; |
|
|
|
/// All UI tests are supposed to be executed from one of the <see cref="Dispatch"/> methods to keep execution flow on the UI thread.
|
|
|
|
/// Disposing unit test session stops internal dispatcher loop.
|
|
|
|
/// </summary>
|
|
|
|
[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<Assembly, HeadlessUnitTestSession> s_session = new(); |
|
|
|
@ -27,19 +26,25 @@ public sealed class HeadlessUnitTestSession : IDisposable |
|
|
|
private readonly CancellationTokenSource _cancellationTokenSource; |
|
|
|
private readonly BlockingCollection<(Action, ExecutionContext?)> _queue; |
|
|
|
private readonly Task _dispatchTask; |
|
|
|
private readonly bool _isolated; |
|
|
|
// Only set and used with PerAssembly isolation
|
|
|
|
private SynchronizationContext? _sharedContext; |
|
|
|
|
|
|
|
internal const DynamicallyAccessedMemberTypes DynamicallyAccessed = |
|
|
|
DynamicallyAccessedMemberTypes.PublicMethods | |
|
|
|
DynamicallyAccessedMemberTypes.NonPublicMethods | |
|
|
|
DynamicallyAccessedMemberTypes.PublicParameterlessConstructor; |
|
|
|
|
|
|
|
private HeadlessUnitTestSession(AppBuilder appBuilder, CancellationTokenSource cancellationTokenSource, |
|
|
|
BlockingCollection<(Action, ExecutionContext?)> queue, Task dispatchTask) |
|
|
|
private HeadlessUnitTestSession( |
|
|
|
AppBuilder appBuilder, CancellationTokenSource cancellationTokenSource, |
|
|
|
BlockingCollection<(Action, ExecutionContext?)> queue, Task dispatchTask, |
|
|
|
bool isolated) |
|
|
|
{ |
|
|
|
_appBuilder = appBuilder; |
|
|
|
_cancellationTokenSource = cancellationTokenSource; |
|
|
|
_queue = queue; |
|
|
|
_dispatchTask = dispatchTask; |
|
|
|
_isolated = isolated; |
|
|
|
} |
|
|
|
|
|
|
|
/// <inheritdoc cref="DispatchCore{TResult}"/>
|
|
|
|
@ -93,7 +98,9 @@ public sealed class HeadlessUnitTestSession : IDisposable |
|
|
|
|
|
|
|
try |
|
|
|
{ |
|
|
|
using var application = EnsureApplication(); |
|
|
|
using var application = _isolated |
|
|
|
? EnsureIsolatedApplication() |
|
|
|
: EnsureSharedApplication(); |
|
|
|
var task = action(); |
|
|
|
if (task.Status != TaskStatus.RanToCompletion) |
|
|
|
{ |
|
|
|
@ -123,7 +130,27 @@ public sealed class HeadlessUnitTestSession : IDisposable |
|
|
|
return tcs.Task; |
|
|
|
} |
|
|
|
|
|
|
|
private IDisposable EnsureApplication() |
|
|
|
private IDisposable EnsureSharedApplication() |
|
|
|
{ |
|
|
|
var oldContext = SynchronizationContext.Current; |
|
|
|
if (Application.Current is null) |
|
|
|
{ |
|
|
|
_appBuilder.SetupUnsafe(); |
|
|
|
_sharedContext = SynchronizationContext.Current; |
|
|
|
} |
|
|
|
else |
|
|
|
{ |
|
|
|
SynchronizationContext.SetSynchronizationContext(_sharedContext); |
|
|
|
} |
|
|
|
|
|
|
|
return Disposable.Create(() => |
|
|
|
{ |
|
|
|
Dispatcher.UIThread.RunJobs(); |
|
|
|
SynchronizationContext.SetSynchronizationContext(oldContext); |
|
|
|
}); |
|
|
|
} |
|
|
|
|
|
|
|
private IDisposable EnsureIsolatedApplication() |
|
|
|
{ |
|
|
|
var scope = AvaloniaLocator.EnterScope(); |
|
|
|
var oldContext = SynchronizationContext.Current; |
|
|
|
@ -167,6 +194,24 @@ public sealed class HeadlessUnitTestSession : IDisposable |
|
|
|
public static HeadlessUnitTestSession StartNew( |
|
|
|
[DynamicallyAccessedMembers(DynamicallyAccessed)] |
|
|
|
Type entryPointType) |
|
|
|
{ |
|
|
|
// Cannot be optional parameter for ABI stability
|
|
|
|
// ReSharper disable once IntroduceOptionalParameters.Global
|
|
|
|
return StartNew(entryPointType, AvaloniaTestIsolationLevel.PerTest); |
|
|
|
} |
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
/// Creates instance of <see cref="HeadlessUnitTestSession"/>.
|
|
|
|
/// </summary>
|
|
|
|
/// <param name="entryPointType">
|
|
|
|
/// Parameter from which <see cref="AppBuilder"/> should be created.
|
|
|
|
/// It either needs to have BuildAvaloniaApp -> AppBuilder method or inherit Application.
|
|
|
|
/// </param>
|
|
|
|
/// <param name="isolationLevel">Defines the isolation level for headless unit tests</param>
|
|
|
|
public static HeadlessUnitTestSession StartNew( |
|
|
|
[DynamicallyAccessedMembers(DynamicallyAccessed)] |
|
|
|
Type entryPointType, |
|
|
|
AvaloniaTestIsolationLevel isolationLevel) |
|
|
|
{ |
|
|
|
var tcs = new TaskCompletionSource<HeadlessUnitTestSession>(); |
|
|
|
var cancellationTokenSource = new CancellationTokenSource(); |
|
|
|
@ -178,6 +223,7 @@ public sealed class HeadlessUnitTestSession : IDisposable |
|
|
|
try |
|
|
|
{ |
|
|
|
var appBuilder = AppBuilder.Configure(entryPointType); |
|
|
|
var runIsolated = isolationLevel == AvaloniaTestIsolationLevel.PerTest; |
|
|
|
|
|
|
|
// If windowing subsystem wasn't initialized by user, force headless with default parameters.
|
|
|
|
if (appBuilder.WindowingSubsystemName != "Headless") |
|
|
|
@ -186,7 +232,7 @@ public sealed class HeadlessUnitTestSession : IDisposable |
|
|
|
} |
|
|
|
|
|
|
|
// ReSharper disable once AccessToModifiedClosure
|
|
|
|
tcs.SetResult(new HeadlessUnitTestSession(appBuilder, cancellationTokenSource, queue, task!)); |
|
|
|
tcs.SetResult(new HeadlessUnitTestSession(appBuilder, cancellationTokenSource, queue, task!, runIsolated)); |
|
|
|
} |
|
|
|
catch (Exception e) |
|
|
|
{ |
|
|
|
@ -234,9 +280,12 @@ public sealed class HeadlessUnitTestSession : IDisposable |
|
|
|
var appBuilderEntryPointType = assembly.GetCustomAttribute<AvaloniaTestApplicationAttribute>() |
|
|
|
?.AppBuilderEntryPointType; |
|
|
|
|
|
|
|
var isolationLevel = assembly.GetCustomAttribute<AvaloniaTestIsolationAttribute>() |
|
|
|
?.IsolationLevel ?? AvaloniaTestIsolationLevel.PerTest; |
|
|
|
|
|
|
|
session = appBuilderEntryPointType is not null ? |
|
|
|
StartNew(appBuilderEntryPointType) : |
|
|
|
StartNew(typeof(Application)); |
|
|
|
StartNew(appBuilderEntryPointType, isolationLevel) : |
|
|
|
StartNew(typeof(Application), isolationLevel); |
|
|
|
|
|
|
|
s_session.Add(assembly, session); |
|
|
|
} |
|
|
|
|