155 changed files with 3109 additions and 2037 deletions
@ -0,0 +1,7 @@ |
|||
<Application xmlns="https://github.com/avaloniaui" |
|||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" |
|||
x:Class="AppWithoutLifetime.App"> |
|||
<Application.Styles> |
|||
<FluentTheme /> |
|||
</Application.Styles> |
|||
</Application> |
|||
@ -0,0 +1,12 @@ |
|||
using Avalonia; |
|||
using Avalonia.Markup.Xaml; |
|||
|
|||
namespace AppWithoutLifetime; |
|||
|
|||
public partial class App : Application |
|||
{ |
|||
public override void Initialize() |
|||
{ |
|||
AvaloniaXamlLoader.Load(this); |
|||
} |
|||
} |
|||
@ -0,0 +1,21 @@ |
|||
<Project Sdk="Microsoft.NET.Sdk"> |
|||
<PropertyGroup> |
|||
<OutputType>WinExe</OutputType> |
|||
<TargetFramework>net6.0</TargetFramework> |
|||
<Nullable>enable</Nullable> |
|||
<ApplicationManifest>app.manifest</ApplicationManifest> |
|||
</PropertyGroup> |
|||
|
|||
<ItemGroup> |
|||
<ProjectReference Include="..\..\src\Avalonia.Diagnostics\Avalonia.Diagnostics.csproj" /> |
|||
<ProjectReference Include="..\..\src\Avalonia.Controls.ColorPicker\Avalonia.Controls.ColorPicker.csproj" /> |
|||
<ProjectReference Include="..\..\src\Avalonia.Controls.DataGrid\Avalonia.Controls.DataGrid.csproj" /> |
|||
<ProjectReference Include="..\..\src\Avalonia.Fonts.Inter\Avalonia.Fonts.Inter.csproj" /> |
|||
<ProjectReference Include="..\..\src\Avalonia.Themes.Fluent\Avalonia.Themes.Fluent.csproj" /> |
|||
</ItemGroup> |
|||
|
|||
<Import Project="..\..\build\SampleApp.props" /> |
|||
<Import Project="..\..\build\ReferenceCoreLibraries.props" /> |
|||
<Import Project="..\..\build\BuildTargets.targets" /> |
|||
|
|||
</Project> |
|||
@ -0,0 +1,13 @@ |
|||
<Window xmlns="https://github.com/avaloniaui" |
|||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" |
|||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" |
|||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" |
|||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" |
|||
x:Class="AppWithoutLifetime.MainWindow" |
|||
Title="AppWithoutLifetime"> |
|||
<StackPanel> |
|||
<TextBlock Text="Welcome to Avalonia!"/> |
|||
<CheckBox Content="Welcome to Avalonia!"/> |
|||
<Button Click="Open" Content="Open"/> |
|||
</StackPanel> |
|||
</Window> |
|||
@ -0,0 +1,30 @@ |
|||
using Avalonia; |
|||
using Avalonia.Controls; |
|||
using Avalonia.Interactivity; |
|||
using Avalonia.Markup.Xaml; |
|||
|
|||
namespace AppWithoutLifetime; |
|||
|
|||
public partial class MainWindow : Window |
|||
{ |
|||
public MainWindow() |
|||
{ |
|||
InitializeComponent(); |
|||
} |
|||
|
|||
private void InitializeComponent() |
|||
{ |
|||
AvaloniaXamlLoader.Load(this); |
|||
} |
|||
|
|||
protected override void OnLoaded() |
|||
{ |
|||
this.AttachDevTools(); |
|||
base.OnLoaded(); |
|||
} |
|||
|
|||
public void Open(object sender, RoutedEventArgs e) |
|||
{ |
|||
new Sub().Show(this); |
|||
} |
|||
} |
|||
@ -0,0 +1,28 @@ |
|||
using Avalonia; |
|||
using Avalonia.Controls; |
|||
using System; |
|||
|
|||
namespace AppWithoutLifetime; |
|||
|
|||
class Program |
|||
{ |
|||
// Initialization code. Don't use any Avalonia, third-party APIs or any
|
|||
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
|
|||
// yet and stuff might break.
|
|||
[STAThread] |
|||
public static void Main(string[] args) |
|||
{ |
|||
BuildAvaloniaApp().Start(AppMain, args); |
|||
} |
|||
|
|||
private static void AppMain(Application app, string[] args) |
|||
{ |
|||
app.Run(new MainWindow()); |
|||
} |
|||
|
|||
// Avalonia configuration, don't remove; also used by visual designer.
|
|||
public static AppBuilder BuildAvaloniaApp() |
|||
=> AppBuilder.Configure<App>() |
|||
.UsePlatformDetect() |
|||
.LogToTrace(); |
|||
} |
|||
@ -0,0 +1,9 @@ |
|||
<Window xmlns="https://github.com/avaloniaui" |
|||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" |
|||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" |
|||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" |
|||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" |
|||
x:Class="AppWithoutLifetime.Sub" |
|||
Title="Window1"> |
|||
Welcome to Avalonia Sub! |
|||
</Window> |
|||
@ -0,0 +1,24 @@ |
|||
using Avalonia; |
|||
using Avalonia.Controls; |
|||
using Avalonia.Markup.Xaml; |
|||
|
|||
namespace AppWithoutLifetime; |
|||
|
|||
public partial class Sub : Window |
|||
{ |
|||
public Sub() |
|||
{ |
|||
InitializeComponent(); |
|||
} |
|||
|
|||
private void InitializeComponent() |
|||
{ |
|||
AvaloniaXamlLoader.Load(this); |
|||
} |
|||
|
|||
protected override void OnLoaded() |
|||
{ |
|||
this.AttachDevTools(); |
|||
base.OnLoaded(); |
|||
} |
|||
} |
|||
@ -0,0 +1,18 @@ |
|||
<?xml version="1.0" encoding="utf-8"?> |
|||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1"> |
|||
<!-- This manifest is used on Windows only. |
|||
Don't remove it as it might cause problems with window transparency and embeded controls. |
|||
For more details visit https://learn.microsoft.com/en-us/windows/win32/sbscs/application-manifests --> |
|||
<assemblyIdentity version="1.0.0.0" name="AppWithoutLifetime"/> |
|||
|
|||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1"> |
|||
<application> |
|||
<!-- A list of the Windows versions that this application has been tested on |
|||
and is designed to work with. Uncomment the appropriate elements |
|||
and Windows will automatically select the most compatible environment. --> |
|||
|
|||
<!-- Windows 10 --> |
|||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" /> |
|||
</application> |
|||
</compatibility> |
|||
</assembly> |
|||
@ -0,0 +1,41 @@ |
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.Collections.Specialized; |
|||
using Avalonia.Controls; |
|||
|
|||
namespace Avalonia.Diagnostics.Controls; |
|||
|
|||
internal class TopLevelGroup : AvaloniaObject |
|||
{ |
|||
public TopLevelGroup(IDevToolsTopLevelGroup group) |
|||
{ |
|||
Group = group; |
|||
if (Group.Items is INotifyCollectionChanged incc) |
|||
{ |
|||
incc.CollectionChanged += (_, args) => |
|||
{ |
|||
if (args.OldItems is not null) |
|||
{ |
|||
foreach (TopLevel oldItem in args.OldItems) |
|||
{ |
|||
Removed?.Invoke(this, oldItem); |
|||
} |
|||
} |
|||
|
|||
if (args.NewItems is not null) |
|||
{ |
|||
foreach (TopLevel newItem in args.NewItems) |
|||
{ |
|||
Added?.Invoke(this, newItem); |
|||
} |
|||
} |
|||
}; |
|||
} |
|||
} |
|||
|
|||
public IDevToolsTopLevelGroup Group { get; } |
|||
|
|||
public IReadOnlyList<TopLevel> Items => Group.Items; |
|||
public event EventHandler<TopLevel>? Added; |
|||
public event EventHandler<TopLevel>? Removed; |
|||
} |
|||
@ -0,0 +1,56 @@ |
|||
using System; |
|||
using System.Collections.Generic; |
|||
using Avalonia.Controls; |
|||
using Avalonia.Controls.ApplicationLifetimes; |
|||
|
|||
namespace Avalonia.Diagnostics; |
|||
|
|||
internal interface IDevToolsTopLevelGroup |
|||
{ |
|||
IReadOnlyList<TopLevel> Items { get; } |
|||
} |
|||
|
|||
internal class ClassicDesktopStyleApplicationLifetimeTopLevelGroup : IDevToolsTopLevelGroup |
|||
{ |
|||
private readonly IClassicDesktopStyleApplicationLifetime _lifetime; |
|||
|
|||
public ClassicDesktopStyleApplicationLifetimeTopLevelGroup(IClassicDesktopStyleApplicationLifetime lifetime) |
|||
{ |
|||
_lifetime = lifetime ?? throw new ArgumentNullException(nameof(lifetime)); |
|||
} |
|||
|
|||
public IReadOnlyList<TopLevel> Items => _lifetime.Windows; |
|||
|
|||
public override int GetHashCode() |
|||
{ |
|||
return _lifetime.GetHashCode(); |
|||
} |
|||
|
|||
public override bool Equals(object? obj) |
|||
{ |
|||
return obj is ClassicDesktopStyleApplicationLifetimeTopLevelGroup g && g._lifetime == _lifetime; |
|||
} |
|||
} |
|||
|
|||
internal class SingleViewTopLevelGroup : IDevToolsTopLevelGroup |
|||
{ |
|||
private readonly TopLevel _topLevel; |
|||
|
|||
public SingleViewTopLevelGroup(TopLevel topLevel) |
|||
{ |
|||
_topLevel = topLevel; |
|||
Items = new[] { topLevel ?? throw new ArgumentNullException(nameof(topLevel)) }; |
|||
} |
|||
|
|||
public IReadOnlyList<TopLevel> Items { get; } |
|||
|
|||
public override int GetHashCode() |
|||
{ |
|||
return _topLevel.GetHashCode(); |
|||
} |
|||
|
|||
public override bool Equals(object? obj) |
|||
{ |
|||
return obj is SingleViewTopLevelGroup g && g._topLevel == _topLevel; |
|||
} |
|||
} |
|||
@ -0,0 +1,78 @@ |
|||
const WRITE = 0; |
|||
const PULL = 0; |
|||
const ERROR = 1; |
|||
const ABORT = 1; |
|||
const CLOSE = 2; |
|||
|
|||
class MessagePortSource implements UnderlyingSource { |
|||
private controller?: ReadableStreamController<any>; |
|||
|
|||
constructor (private readonly port: MessagePort) { |
|||
this.port.onmessage = evt => this.onMessage(evt.data); |
|||
} |
|||
|
|||
start (controller: ReadableStreamController<any>) { |
|||
this.controller = controller; |
|||
} |
|||
|
|||
cancel (reason: Error) { |
|||
// Firefox can notify a cancel event, chrome can't
|
|||
// https://bugs.chromium.org/p/chromium/issues/detail?id=638494
|
|||
this.port.postMessage({ type: ERROR, reason: reason.message }); |
|||
this.port.close(); |
|||
} |
|||
|
|||
onMessage (message: { type: number; chunk: Uint8Array; reason: any }) { |
|||
// enqueue() will call pull() if needed when there's no backpressure
|
|||
if (!this.controller) { |
|||
return; |
|||
} |
|||
if (message.type === WRITE) { |
|||
this.controller.enqueue(message.chunk); |
|||
this.port.postMessage({ type: PULL }); |
|||
} |
|||
if (message.type === ABORT) { |
|||
this.controller.error(message.reason); |
|||
this.port.close(); |
|||
} |
|||
if (message.type === CLOSE) { |
|||
this.controller.close(); |
|||
this.port.close(); |
|||
} |
|||
} |
|||
} |
|||
|
|||
self.addEventListener("install", () => { |
|||
(self as any).skipWaiting(); |
|||
}); |
|||
|
|||
self.addEventListener("activate", event /* ExtendableEvent */ => { |
|||
(event as any).waitUntil((self as any).clients.claim()); |
|||
}); |
|||
|
|||
const map = new Map(); |
|||
|
|||
// This should be called once per download
|
|||
// Each event has a dataChannel that the data will be piped through
|
|||
globalThis.addEventListener("message", evt => { |
|||
const data = evt.data; |
|||
if (data.url && data.readablePort) { |
|||
data.rs = new ReadableStream( |
|||
new MessagePortSource(evt.data.readablePort), |
|||
new CountQueuingStrategy({ highWaterMark: 4 }) |
|||
); |
|||
map.set(data.url, data); |
|||
} |
|||
}); |
|||
|
|||
globalThis.addEventListener("fetch", evt => { |
|||
const url = (evt as any).request.url; |
|||
const data = map.get(url); |
|||
if (!data) return null; |
|||
map.delete(url); |
|||
(evt as any).respondWith(new Response(data.rs, { |
|||
headers: data.headers |
|||
})); |
|||
}); |
|||
|
|||
export {}; |
|||
@ -0,0 +1,19 @@ |
|||
<Project Sdk="Microsoft.NET.Sdk"> |
|||
<PropertyGroup> |
|||
<TargetFrameworks>netstandard2.0;net6.0</TargetFrameworks> |
|||
<IsTestProject>false</IsTestProject> |
|||
</PropertyGroup> |
|||
|
|||
<ItemGroup> |
|||
<!-- Use lower minor version, as it is supposed to be compatible --> |
|||
<PackageReference Include="NUnit" Version="3.13.0" /> |
|||
</ItemGroup> |
|||
|
|||
<ItemGroup> |
|||
<ProjectReference Include="..\Avalonia.Headless\Avalonia.Headless.csproj" /> |
|||
</ItemGroup> |
|||
|
|||
<Import Project="..\..\..\build\ApiDiff.props" /> |
|||
<Import Project="..\..\..\build\DevAnalyzers.props" /> |
|||
<Import Project="..\..\..\build\NullableEnable.props" /> |
|||
</Project> |
|||
@ -0,0 +1,25 @@ |
|||
using System; |
|||
using System.Linq; |
|||
using System.Reflection; |
|||
using System.Threading; |
|||
using NUnit.Framework; |
|||
using NUnit.Framework.Interfaces; |
|||
using NUnit.Framework.Internal.Commands; |
|||
|
|||
namespace Avalonia.Headless.NUnit; |
|||
|
|||
/// <summary>
|
|||
/// Identifies a nunit test that starts on Avalonia Dispatcher
|
|||
/// such that awaited expressions resume on the test's "main thread".
|
|||
/// </summary>
|
|||
[AttributeUsage(AttributeTargets.Method, AllowMultiple = true, Inherited = false)] |
|||
public sealed class AvaloniaTestAttribute : TestCaseAttribute, IWrapSetUpTearDown |
|||
{ |
|||
public TestCommand Wrap(TestCommand command) |
|||
{ |
|||
var session = |
|||
HeadlessUnitTestSession.GetOrStartForAssembly(command.Test.Method?.MethodInfo.DeclaringType?.Assembly); |
|||
|
|||
return AvaloniaTestMethodCommand.ProcessCommand(session, command); |
|||
} |
|||
} |
|||
@ -0,0 +1,116 @@ |
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.Reflection; |
|||
using System.Threading.Tasks; |
|||
using Avalonia.Threading; |
|||
using NUnit.Framework.Interfaces; |
|||
using NUnit.Framework.Internal; |
|||
using NUnit.Framework.Internal.Commands; |
|||
|
|||
namespace Avalonia.Headless.NUnit; |
|||
|
|||
internal class AvaloniaTestMethodCommand : TestCommand |
|||
{ |
|||
private readonly HeadlessUnitTestSession _session; |
|||
private readonly TestCommand _innerCommand; |
|||
private readonly List<Action> _beforeTest; |
|||
private readonly List<Action> _afterTest; |
|||
|
|||
// There are multiple problems with NUnit integration at the moment when we wrote this integration.
|
|||
// NUnit doesn't have extensibility API for running on custom dispatcher/sync-context.
|
|||
// See https://github.com/nunit/nunit/issues/2917 https://github.com/nunit/nunit/issues/2774
|
|||
// To workaround that we had to replace inner TestMethodCommand with our own implementation while keeping original hierarchy of commands.
|
|||
// Which will respect proper async/await awaiting code that works with our session and can be block-awaited to fit in NUnit.
|
|||
// Also, we need to push BeforeTest/AfterTest callbacks to the very same session call.
|
|||
// I hope there will be a better solution without reflection, but for now that's it.
|
|||
private static FieldInfo s_innerCommand = typeof(DelegatingTestCommand) |
|||
.GetField("innerCommand", BindingFlags.Instance | BindingFlags.NonPublic)!; |
|||
private static FieldInfo s_beforeTest = typeof(BeforeAndAfterTestCommand) |
|||
.GetField("BeforeTest", BindingFlags.Instance | BindingFlags.NonPublic)!; |
|||
private static FieldInfo s_afterTest = typeof(BeforeAndAfterTestCommand) |
|||
.GetField("AfterTest", BindingFlags.Instance | BindingFlags.NonPublic)!; |
|||
|
|||
private AvaloniaTestMethodCommand( |
|||
HeadlessUnitTestSession session, |
|||
TestCommand innerCommand, |
|||
List<Action> beforeTest, |
|||
List<Action> afterTest) |
|||
: base(innerCommand.Test) |
|||
{ |
|||
_session = session; |
|||
_innerCommand = innerCommand; |
|||
_beforeTest = beforeTest; |
|||
_afterTest = afterTest; |
|||
} |
|||
|
|||
public static TestCommand ProcessCommand(HeadlessUnitTestSession session, TestCommand command) |
|||
{ |
|||
return ProcessCommand(session, command, new List<Action>(), new List<Action>()); |
|||
} |
|||
|
|||
private static TestCommand ProcessCommand(HeadlessUnitTestSession session, TestCommand command, List<Action> before, List<Action> after) |
|||
{ |
|||
if (command is BeforeAndAfterTestCommand beforeAndAfterTestCommand) |
|||
{ |
|||
if (s_beforeTest.GetValue(beforeAndAfterTestCommand) is Action<TestExecutionContext> beforeTest) |
|||
{ |
|||
Action<TestExecutionContext> beforeAction = c => before.Add(() => beforeTest(c)); |
|||
s_beforeTest.SetValue(beforeAndAfterTestCommand, beforeAction); |
|||
} |
|||
if (s_afterTest.GetValue(beforeAndAfterTestCommand) is Action<TestExecutionContext> afterTest) |
|||
{ |
|||
Action<TestExecutionContext> afterAction = c => after.Add(() => afterTest(c)); |
|||
s_afterTest.SetValue(beforeAndAfterTestCommand, afterAction); |
|||
} |
|||
} |
|||
|
|||
if (command is DelegatingTestCommand delegatingTestCommand |
|||
&& s_innerCommand.GetValue(delegatingTestCommand) is TestCommand inner) |
|||
{ |
|||
s_innerCommand.SetValue(delegatingTestCommand, ProcessCommand(session, inner, before, after)); |
|||
} |
|||
else if (command is TestMethodCommand methodCommand) |
|||
{ |
|||
return new AvaloniaTestMethodCommand(session, methodCommand, before, after); |
|||
} |
|||
|
|||
return command; |
|||
} |
|||
|
|||
public override TestResult Execute(TestExecutionContext context) |
|||
{ |
|||
return _session.Dispatch(() => ExecuteTestMethod(context), default).GetAwaiter().GetResult(); |
|||
} |
|||
|
|||
// Unfortunately, NUnit has issues with custom synchronization contexts, which means we need to add some hacks to make it work.
|
|||
private async Task<TestResult> ExecuteTestMethod(TestExecutionContext context) |
|||
{ |
|||
_beforeTest.ForEach(a => a()); |
|||
|
|||
var testMethod = _innerCommand.Test.Method; |
|||
var methodInfo = testMethod!.MethodInfo; |
|||
|
|||
var result = methodInfo.Invoke(context.TestObject, _innerCommand.Test.Arguments); |
|||
// Only Task, non generic ValueTask are supported in async context. No ValueTask<> nor F# tasks.
|
|||
if (result is Task task) |
|||
{ |
|||
await task; |
|||
} |
|||
else if (result is ValueTask valueTask) |
|||
{ |
|||
await valueTask; |
|||
} |
|||
|
|||
context.CurrentResult.SetResult(ResultState.Success); |
|||
|
|||
if (context.CurrentResult.AssertionResults.Count > 0) |
|||
context.CurrentResult.RecordTestCompletion(); |
|||
|
|||
if (context.ExecutionStatus != TestExecutionStatus.AbortRequested) |
|||
{ |
|||
_afterTest.ForEach(a => a()); |
|||
} |
|||
|
|||
return context.CurrentResult; |
|||
} |
|||
} |
|||
@ -0,0 +1,24 @@ |
|||
using System; |
|||
using System.Linq; |
|||
using System.Reflection; |
|||
using System.Threading; |
|||
using NUnit.Framework; |
|||
using NUnit.Framework.Interfaces; |
|||
using NUnit.Framework.Internal.Commands; |
|||
|
|||
namespace Avalonia.Headless.NUnit; |
|||
|
|||
/// <summary>
|
|||
/// Identifies a nunit theory that starts on Avalonia Dispatcher
|
|||
/// such that awaited expressions resume on the test's "main thread".
|
|||
/// </summary>
|
|||
[AttributeUsage(AttributeTargets.Method, AllowMultiple = true, Inherited = false)] |
|||
public sealed class AvaloniaTheoryAttribute : TheoryAttribute, IWrapSetUpTearDown |
|||
{ |
|||
public TestCommand Wrap(TestCommand command) |
|||
{ |
|||
var session = HeadlessUnitTestSession.GetOrStartForAssembly(command.Test.Method?.MethodInfo.DeclaringType?.Assembly); |
|||
|
|||
return AvaloniaTestMethodCommand.ProcessCommand(session, command); |
|||
} |
|||
} |
|||
@ -0,0 +1,35 @@ |
|||
using System; |
|||
using System.ComponentModel; |
|||
using System.Threading; |
|||
using Xunit; |
|||
using Xunit.Abstractions; |
|||
using Xunit.Sdk; |
|||
|
|||
namespace Avalonia.Headless.XUnit; |
|||
|
|||
/// <summary>
|
|||
/// Identifies an xunit test that starts on Avalonia Dispatcher
|
|||
/// such that awaited expressions resume on the test's "main thread".
|
|||
/// </summary>
|
|||
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] |
|||
[XunitTestCaseDiscoverer("Avalonia.Headless.XUnit.AvaloniaUIFactDiscoverer", "Avalonia.Headless.XUnit")] |
|||
public sealed class AvaloniaFactAttribute : FactAttribute |
|||
{ |
|||
|
|||
} |
|||
|
|||
[EditorBrowsable(EditorBrowsableState.Never)] |
|||
public class AvaloniaUIFactDiscoverer : FactDiscoverer |
|||
{ |
|||
private readonly IMessageSink diagnosticMessageSink; |
|||
public AvaloniaUIFactDiscoverer(IMessageSink diagnosticMessageSink) |
|||
: base(diagnosticMessageSink) |
|||
{ |
|||
this.diagnosticMessageSink = diagnosticMessageSink; |
|||
} |
|||
|
|||
protected override IXunitTestCase CreateTestCase(ITestFrameworkDiscoveryOptions discoveryOptions, ITestMethod testMethod, IAttributeInfo factAttribute) |
|||
{ |
|||
return new AvaloniaTestCase(diagnosticMessageSink, discoveryOptions.MethodDisplayOrDefault(), testMethod); |
|||
} |
|||
} |
|||
@ -0,0 +1,126 @@ |
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.Reflection; |
|||
using System.Threading; |
|||
using System.Threading.Tasks; |
|||
using Avalonia.Threading; |
|||
using Xunit.Abstractions; |
|||
using Xunit.Sdk; |
|||
|
|||
namespace Avalonia.Headless.XUnit; |
|||
|
|||
internal class AvaloniaTestAssemblyRunner : XunitTestAssemblyRunner |
|||
{ |
|||
private HeadlessUnitTestSession? _session; |
|||
|
|||
public AvaloniaTestAssemblyRunner(ITestAssembly testAssembly, IEnumerable<IXunitTestCase> testCases, |
|||
IMessageSink diagnosticMessageSink, IMessageSink executionMessageSink, |
|||
ITestFrameworkExecutionOptions executionOptions) : base(testAssembly, testCases, diagnosticMessageSink, |
|||
executionMessageSink, executionOptions) |
|||
{ |
|||
} |
|||
|
|||
protected override void SetupSyncContext(int maxParallelThreads) |
|||
{ |
|||
_session = HeadlessUnitTestSession.GetOrStartForAssembly( |
|||
Assembly.Load(new AssemblyName(TestAssembly.Assembly.Name))); |
|||
base.SetupSyncContext(1); |
|||
} |
|||
|
|||
public override void Dispose() |
|||
{ |
|||
_session?.Dispose(); |
|||
base.Dispose(); |
|||
} |
|||
|
|||
protected override Task<RunSummary> RunTestCollectionAsync( |
|||
IMessageBus messageBus, |
|||
ITestCollection testCollection, |
|||
IEnumerable<IXunitTestCase> testCases, |
|||
CancellationTokenSource cancellationTokenSource) |
|||
{ |
|||
return new AvaloniaTestCollectionRunner(_session!, testCollection, testCases, DiagnosticMessageSink, messageBus, |
|||
TestCaseOrderer, new ExceptionAggregator(Aggregator), cancellationTokenSource).RunAsync(); |
|||
} |
|||
|
|||
private class AvaloniaTestCollectionRunner : XunitTestCollectionRunner |
|||
{ |
|||
private readonly HeadlessUnitTestSession _session; |
|||
|
|||
public AvaloniaTestCollectionRunner(HeadlessUnitTestSession session, |
|||
ITestCollection testCollection, IEnumerable<IXunitTestCase> testCases, |
|||
IMessageSink diagnosticMessageSink, IMessageBus messageBus, ITestCaseOrderer testCaseOrderer, |
|||
ExceptionAggregator aggregator, CancellationTokenSource cancellationTokenSource) : base(testCollection, |
|||
testCases, diagnosticMessageSink, messageBus, testCaseOrderer, aggregator, cancellationTokenSource) |
|||
{ |
|||
_session = session; |
|||
} |
|||
|
|||
protected override Task<RunSummary> RunTestClassAsync( |
|||
ITestClass testClass, |
|||
IReflectionTypeInfo @class, |
|||
IEnumerable<IXunitTestCase> testCases) |
|||
{ |
|||
return new AvaloniaTestClassRunner(_session, testClass, @class, testCases, DiagnosticMessageSink, MessageBus, |
|||
TestCaseOrderer, new ExceptionAggregator(Aggregator), CancellationTokenSource, |
|||
CollectionFixtureMappings).RunAsync(); |
|||
} |
|||
} |
|||
|
|||
private class AvaloniaTestClassRunner : XunitTestClassRunner |
|||
{ |
|||
private readonly HeadlessUnitTestSession _session; |
|||
|
|||
public AvaloniaTestClassRunner(HeadlessUnitTestSession session, ITestClass testClass, |
|||
IReflectionTypeInfo @class, |
|||
IEnumerable<IXunitTestCase> testCases, IMessageSink diagnosticMessageSink, IMessageBus messageBus, |
|||
ITestCaseOrderer testCaseOrderer, ExceptionAggregator aggregator, |
|||
CancellationTokenSource cancellationTokenSource, IDictionary<Type, object> collectionFixtureMappings) : |
|||
base(testClass, @class, testCases, diagnosticMessageSink, messageBus, testCaseOrderer, aggregator, |
|||
cancellationTokenSource, collectionFixtureMappings) |
|||
{ |
|||
_session = session; |
|||
} |
|||
|
|||
protected override Task<RunSummary> RunTestMethodAsync( |
|||
ITestMethod testMethod, |
|||
IReflectionMethodInfo method, |
|||
IEnumerable<IXunitTestCase> testCases, |
|||
object[] constructorArguments) |
|||
{ |
|||
return new AvaloniaTestMethodRunner(_session, testMethod, Class, method, testCases, DiagnosticMessageSink, |
|||
MessageBus, new ExceptionAggregator(Aggregator), CancellationTokenSource, |
|||
constructorArguments).RunAsync(); |
|||
} |
|||
} |
|||
|
|||
private class AvaloniaTestMethodRunner : XunitTestMethodRunner |
|||
{ |
|||
private readonly HeadlessUnitTestSession _session; |
|||
private readonly IMessageBus _messageBus; |
|||
private readonly ExceptionAggregator _aggregator; |
|||
private readonly CancellationTokenSource _cancellationTokenSource; |
|||
private readonly object[] _constructorArguments; |
|||
|
|||
public AvaloniaTestMethodRunner(HeadlessUnitTestSession session, ITestMethod testMethod, |
|||
IReflectionTypeInfo @class, |
|||
IReflectionMethodInfo method, IEnumerable<IXunitTestCase> testCases, IMessageSink diagnosticMessageSink, |
|||
IMessageBus messageBus, ExceptionAggregator aggregator, CancellationTokenSource cancellationTokenSource, |
|||
object[] constructorArguments) : base(testMethod, @class, method, testCases, diagnosticMessageSink, |
|||
messageBus, aggregator, cancellationTokenSource, constructorArguments) |
|||
{ |
|||
_session = session; |
|||
_messageBus = messageBus; |
|||
_aggregator = aggregator; |
|||
_cancellationTokenSource = cancellationTokenSource; |
|||
_constructorArguments = constructorArguments; |
|||
} |
|||
|
|||
protected override Task<RunSummary> RunTestCaseAsync(IXunitTestCase testCase) |
|||
{ |
|||
return AvaloniaTestCaseRunner.RunTest(_session, testCase, testCase.DisplayName, testCase.SkipReason, |
|||
_constructorArguments, testCase.TestMethodArguments, _messageBus, _aggregator, |
|||
_cancellationTokenSource); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,47 @@ |
|||
using System; |
|||
using System.ComponentModel; |
|||
using System.Runtime.ExceptionServices; |
|||
using System.Threading; |
|||
using System.Threading.Tasks; |
|||
using Avalonia.Threading; |
|||
using Xunit.Abstractions; |
|||
using Xunit.Sdk; |
|||
|
|||
namespace Avalonia.Headless.XUnit; |
|||
|
|||
internal class AvaloniaTestCase : XunitTestCase |
|||
{ |
|||
public AvaloniaTestCase( |
|||
IMessageSink diagnosticMessageSink, |
|||
TestMethodDisplay defaultMethodDisplay, |
|||
ITestMethod testMethod, |
|||
object?[]? testMethodArguments = null) |
|||
: base(diagnosticMessageSink, defaultMethodDisplay, TestMethodDisplayOptions.None, testMethod, testMethodArguments) |
|||
{ |
|||
} |
|||
|
|||
[EditorBrowsable(EditorBrowsableState.Never)] |
|||
[Obsolete("Called by the de-serializer; should only be called by deriving classes for de-serialization purposes")] |
|||
public AvaloniaTestCase() |
|||
{ |
|||
} |
|||
|
|||
public override Task<RunSummary> RunAsync( |
|||
IMessageSink diagnosticMessageSink, |
|||
IMessageBus messageBus, |
|||
object[] constructorArguments, |
|||
ExceptionAggregator aggregator, |
|||
CancellationTokenSource cancellationTokenSource) |
|||
{ |
|||
var session = HeadlessUnitTestSession.GetOrStartForAssembly(Method.ToRuntimeMethod().DeclaringType?.Assembly); |
|||
|
|||
// We need to block the XUnit thread to ensure its concurrency throttle is effective.
|
|||
// See https://github.com/AArnott/Xunit.StaFact/pull/55#issuecomment-826187354 for details.
|
|||
var runSummary = AvaloniaTestCaseRunner |
|||
.RunTest(session, this, DisplayName, SkipReason, constructorArguments, |
|||
TestMethodArguments, messageBus, aggregator, cancellationTokenSource) |
|||
.GetAwaiter().GetResult(); |
|||
|
|||
return Task.FromResult(runSummary); |
|||
} |
|||
} |
|||
@ -0,0 +1,98 @@ |
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.Reflection; |
|||
using System.Threading; |
|||
using System.Threading.Tasks; |
|||
using Avalonia.Threading; |
|||
using Xunit.Abstractions; |
|||
using Xunit.Sdk; |
|||
|
|||
namespace Avalonia.Headless.XUnit; |
|||
|
|||
internal class AvaloniaTestCaseRunner : XunitTestCaseRunner |
|||
{ |
|||
private readonly Action? _onAfterTestInvoked; |
|||
|
|||
public AvaloniaTestCaseRunner( |
|||
Action? onAfterTestInvoked, |
|||
IXunitTestCase testCase, string displayName, string skipReason, object[] constructorArguments, |
|||
object[] testMethodArguments, IMessageBus messageBus, ExceptionAggregator aggregator, |
|||
CancellationTokenSource cancellationTokenSource) : base(testCase, displayName, skipReason, constructorArguments, |
|||
testMethodArguments, messageBus, aggregator, cancellationTokenSource) |
|||
{ |
|||
_onAfterTestInvoked = onAfterTestInvoked; |
|||
} |
|||
|
|||
public static Task<RunSummary> RunTest(HeadlessUnitTestSession session, |
|||
IXunitTestCase testCase, string displayName, string skipReason, object[] constructorArguments, |
|||
object[] testMethodArguments, IMessageBus messageBus, ExceptionAggregator aggregator, |
|||
CancellationTokenSource cancellationTokenSource) |
|||
{ |
|||
var afterTest = () => Dispatcher.UIThread.RunJobs(); |
|||
return session.Dispatch(async () => |
|||
{ |
|||
var runner = new AvaloniaTestCaseRunner(afterTest, testCase, displayName, |
|||
skipReason, constructorArguments, testMethodArguments, messageBus, aggregator, cancellationTokenSource); |
|||
return await runner.RunAsync(); |
|||
}, cancellationTokenSource.Token); |
|||
} |
|||
|
|||
protected override XunitTestRunner CreateTestRunner(ITest test, IMessageBus messageBus, Type testClass, |
|||
object[] constructorArguments, |
|||
MethodInfo testMethod, object[] testMethodArguments, string skipReason, |
|||
IReadOnlyList<BeforeAfterTestAttribute> beforeAfterAttributes, |
|||
ExceptionAggregator aggregator, CancellationTokenSource cancellationTokenSource) |
|||
{ |
|||
return new AvaloniaTestRunner(_onAfterTestInvoked, test, messageBus, testClass, constructorArguments, |
|||
testMethod, testMethodArguments, skipReason, beforeAfterAttributes, aggregator, cancellationTokenSource); |
|||
} |
|||
|
|||
private class AvaloniaTestRunner : XunitTestRunner |
|||
{ |
|||
private readonly Action? _onAfterTestInvoked; |
|||
|
|||
public AvaloniaTestRunner( |
|||
Action? onAfterTestInvoked, |
|||
ITest test, IMessageBus messageBus, Type testClass, object[] constructorArguments, MethodInfo testMethod, |
|||
object[] testMethodArguments, string skipReason, |
|||
IReadOnlyList<BeforeAfterTestAttribute> beforeAfterAttributes, ExceptionAggregator aggregator, |
|||
CancellationTokenSource cancellationTokenSource) : base(test, messageBus, testClass, constructorArguments, |
|||
testMethod, testMethodArguments, skipReason, beforeAfterAttributes, aggregator, cancellationTokenSource) |
|||
{ |
|||
_onAfterTestInvoked = onAfterTestInvoked; |
|||
} |
|||
|
|||
protected override Task<decimal> InvokeTestMethodAsync(ExceptionAggregator aggregator) |
|||
{ |
|||
return new AvaloniaTestInvoker(_onAfterTestInvoked, Test, MessageBus, TestClass, ConstructorArguments, |
|||
TestMethod, TestMethodArguments, BeforeAfterAttributes, aggregator, CancellationTokenSource).RunAsync(); |
|||
} |
|||
} |
|||
|
|||
private class AvaloniaTestInvoker : XunitTestInvoker |
|||
{ |
|||
private readonly Action? _onAfterTestInvoked; |
|||
|
|||
public AvaloniaTestInvoker( |
|||
Action? onAfterTestInvoked, |
|||
ITest test, IMessageBus messageBus, Type testClass, object[] constructorArguments, MethodInfo testMethod, |
|||
object[] testMethodArguments, IReadOnlyList<BeforeAfterTestAttribute> beforeAfterAttributes, |
|||
ExceptionAggregator aggregator, CancellationTokenSource cancellationTokenSource) : base(test, messageBus, |
|||
testClass, constructorArguments, testMethod, testMethodArguments, beforeAfterAttributes, aggregator, |
|||
cancellationTokenSource) |
|||
{ |
|||
_onAfterTestInvoked = onAfterTestInvoked; |
|||
} |
|||
|
|||
protected override async Task AfterTestMethodInvokedAsync() |
|||
{ |
|||
await base.AfterTestMethodInvokedAsync(); |
|||
|
|||
// Only here we can execute random code after the test, where exception will be properly handled by the XUnit.
|
|||
if (_onAfterTestInvoked is not null) |
|||
{ |
|||
Aggregator.Run(_onAfterTestInvoked); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -1,61 +0,0 @@ |
|||
using Avalonia.Threading; |
|||
using Xunit.Abstractions; |
|||
using Xunit.Sdk; |
|||
|
|||
namespace Avalonia.Headless.XUnit; |
|||
|
|||
internal class AvaloniaTestRunner<TAppBuilderEntry> : XunitTestAssemblyRunner |
|||
{ |
|||
private CancellationTokenSource? _cancellationTokenSource; |
|||
|
|||
public AvaloniaTestRunner(ITestAssembly testAssembly, IEnumerable<IXunitTestCase> testCases, |
|||
IMessageSink diagnosticMessageSink, IMessageSink executionMessageSink, |
|||
ITestFrameworkExecutionOptions executionOptions) : base(testAssembly, testCases, diagnosticMessageSink, |
|||
executionMessageSink, executionOptions) |
|||
{ |
|||
} |
|||
|
|||
protected override void SetupSyncContext(int maxParallelThreads) |
|||
{ |
|||
_cancellationTokenSource?.Dispose(); |
|||
_cancellationTokenSource = new CancellationTokenSource(); |
|||
SynchronizationContext.SetSynchronizationContext(InitNewApplicationContext(_cancellationTokenSource.Token).Result); |
|||
} |
|||
|
|||
public override void Dispose() |
|||
{ |
|||
_cancellationTokenSource?.Cancel(); |
|||
base.Dispose(); |
|||
} |
|||
|
|||
internal static Task<SynchronizationContext> InitNewApplicationContext(CancellationToken cancellationToken) |
|||
{ |
|||
var tcs = new TaskCompletionSource<SynchronizationContext>(); |
|||
|
|||
new Thread(() => |
|||
{ |
|||
try |
|||
{ |
|||
var appBuilder = AppBuilder.Configure(typeof(TAppBuilderEntry)); |
|||
|
|||
// If windowing subsystem wasn't initialized by user, force headless with default parameters.
|
|||
if (appBuilder.WindowingSubsystemName != "Headless") |
|||
{ |
|||
appBuilder = appBuilder.UseHeadless(new AvaloniaHeadlessPlatformOptions()); |
|||
} |
|||
|
|||
appBuilder.SetupWithoutStarting(); |
|||
|
|||
tcs.SetResult(SynchronizationContext.Current!); |
|||
} |
|||
catch (Exception e) |
|||
{ |
|||
tcs.SetException(e); |
|||
} |
|||
|
|||
Dispatcher.UIThread.MainLoop(cancellationToken); |
|||
}) { IsBackground = true }.Start(); |
|||
|
|||
return tcs.Task; |
|||
} |
|||
} |
|||
@ -0,0 +1,37 @@ |
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.ComponentModel; |
|||
using Xunit; |
|||
using Xunit.Abstractions; |
|||
using Xunit.Sdk; |
|||
|
|||
namespace Avalonia.Headless.XUnit; |
|||
|
|||
/// <summary>
|
|||
/// Identifies an xunit theory that starts on Avalonia Dispatcher
|
|||
/// such that awaited expressions resume on the test's "main thread".
|
|||
/// </summary>
|
|||
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] |
|||
[XunitTestCaseDiscoverer("Avalonia.Headless.XUnit.AvaloniaTheoryDiscoverer", "Avalonia.Headless.XUnit")] |
|||
public sealed class AvaloniaTheoryAttribute : TheoryAttribute |
|||
{ |
|||
} |
|||
|
|||
[EditorBrowsable(EditorBrowsableState.Never)] |
|||
public class AvaloniaTheoryDiscoverer : TheoryDiscoverer |
|||
{ |
|||
public AvaloniaTheoryDiscoverer(IMessageSink diagnosticMessageSink) |
|||
: base(diagnosticMessageSink) |
|||
{ |
|||
} |
|||
|
|||
protected override IEnumerable<IXunitTestCase> CreateTestCasesForDataRow(ITestFrameworkDiscoveryOptions discoveryOptions, ITestMethod testMethod, IAttributeInfo theoryAttribute, object[] dataRow) |
|||
{ |
|||
yield return new AvaloniaTestCase(DiagnosticMessageSink, discoveryOptions.MethodDisplayOrDefault(), testMethod, dataRow); |
|||
} |
|||
|
|||
protected override IEnumerable<IXunitTestCase> CreateTestCasesForTheory(ITestFrameworkDiscoveryOptions discoveryOptions, ITestMethod testMethod, IAttributeInfo theoryAttribute) |
|||
{ |
|||
yield return new AvaloniaTheoryTestCase(DiagnosticMessageSink, discoveryOptions.MethodDisplayOrDefault(), TestMethodDisplayOptions.None, testMethod); |
|||
} |
|||
} |
|||
@ -0,0 +1,31 @@ |
|||
using System; |
|||
using System.ComponentModel; |
|||
using System.Threading; |
|||
using System.Threading.Tasks; |
|||
using Xunit.Abstractions; |
|||
using Xunit.Sdk; |
|||
|
|||
namespace Avalonia.Headless.XUnit; |
|||
|
|||
internal class AvaloniaTheoryTestCase : XunitTheoryTestCase |
|||
{ |
|||
[EditorBrowsable(EditorBrowsableState.Never)] |
|||
[Obsolete("Called by the de-serializer; should only be called by deriving classes for de-serialization purposes")] |
|||
public AvaloniaTheoryTestCase() |
|||
{ |
|||
} |
|||
|
|||
public AvaloniaTheoryTestCase(IMessageSink diagnosticMessageSink, TestMethodDisplay defaultMethodDisplay, TestMethodDisplayOptions defaultMethodDisplayOptions, ITestMethod testMethod) |
|||
: base(diagnosticMessageSink, defaultMethodDisplay, defaultMethodDisplayOptions, testMethod) |
|||
{ |
|||
} |
|||
|
|||
public override Task<RunSummary> RunAsync(IMessageSink diagnosticMessageSink, IMessageBus messageBus, object[] constructorArguments, ExceptionAggregator aggregator, CancellationTokenSource cancellationTokenSource) |
|||
{ |
|||
var session = HeadlessUnitTestSession.GetOrStartForAssembly(Method.ToRuntimeMethod().DeclaringType?.Assembly); |
|||
|
|||
return AvaloniaTestCaseRunner |
|||
.RunTest(session, this, DisplayName, SkipReason, constructorArguments, |
|||
TestMethodArguments, messageBus, aggregator, cancellationTokenSource); |
|||
} |
|||
} |
|||
@ -0,0 +1,27 @@ |
|||
using System; |
|||
using System.Diagnostics.CodeAnalysis; |
|||
|
|||
namespace Avalonia.Headless; |
|||
|
|||
/// <summary>
|
|||
/// Sets up global avalonia test framework using avalonia application builder passed as a parameter.
|
|||
/// </summary>
|
|||
[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = false)] |
|||
public sealed class AvaloniaTestApplicationAttribute : Attribute |
|||
{ |
|||
public Type AppBuilderEntryPointType { get; } |
|||
|
|||
/// <summary>
|
|||
/// Creates instance of <see cref="AvaloniaTestApplicationAttribute"/>.
|
|||
/// </summary>
|
|||
/// <param name="appBuilderEntryPointType">
|
|||
/// Parameter from which <see cref="AppBuilder"/> should be created.
|
|||
/// It either needs to have BuildAvaloniaApp -> AppBuilder method or inherit Application.
|
|||
/// </param>
|
|||
public AvaloniaTestApplicationAttribute( |
|||
[DynamicallyAccessedMembers(HeadlessUnitTestSession.DynamicallyAccessed)] |
|||
Type appBuilderEntryPointType) |
|||
{ |
|||
AppBuilderEntryPointType = appBuilderEntryPointType; |
|||
} |
|||
} |
|||
@ -0,0 +1,214 @@ |
|||
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.Metadata; |
|||
using Avalonia.Reactive; |
|||
using Avalonia.Rendering; |
|||
using Avalonia.Threading; |
|||
|
|||
namespace Avalonia.Headless; |
|||
|
|||
/// <summary>
|
|||
/// 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 <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 ConcurrentDictionary<Assembly, HeadlessUnitTestSession> s_session = new(); |
|||
|
|||
private readonly AppBuilder _appBuilder; |
|||
private readonly CancellationTokenSource _cancellationTokenSource; |
|||
private readonly BlockingCollection<Action> _queue; |
|||
private readonly Task _dispatchTask; |
|||
|
|||
internal const DynamicallyAccessedMemberTypes DynamicallyAccessed = |
|||
DynamicallyAccessedMemberTypes.PublicMethods | |
|||
DynamicallyAccessedMemberTypes.NonPublicMethods | |
|||
DynamicallyAccessedMemberTypes.PublicParameterlessConstructor; |
|||
|
|||
private HeadlessUnitTestSession(AppBuilder appBuilder, CancellationTokenSource cancellationTokenSource, |
|||
BlockingCollection<Action> queue, Task dispatchTask) |
|||
{ |
|||
_appBuilder = appBuilder; |
|||
_cancellationTokenSource = cancellationTokenSource; |
|||
_queue = queue; |
|||
_dispatchTask = dispatchTask; |
|||
} |
|||
|
|||
/// <inheritdoc cref="Dispatch{TResult}(Func{Task{TResult}}, CancellationToken)"/>
|
|||
public Task Dispatch(Action action, CancellationToken cancellationToken) |
|||
{ |
|||
return Dispatch(() => |
|||
{ |
|||
action(); |
|||
return Task.FromResult(0); |
|||
}, cancellationToken); |
|||
} |
|||
|
|||
/// <inheritdoc cref="Dispatch{TResult}(Func{Task{TResult}}, CancellationToken)"/>
|
|||
public Task<TResult> Dispatch<TResult>(Func<TResult> action, CancellationToken cancellationToken) |
|||
{ |
|||
return Dispatch(() => Task.FromResult(action()), cancellationToken); |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Dispatch method queues an async operation on the dispatcher thread, creates a new application instance,
|
|||
/// setting app avalonia services, and runs <see cref="action"/> parameter.
|
|||
/// </summary>
|
|||
/// <param name="action">Action to execute on the dispatcher thread with avalonia services.</param>
|
|||
/// <param name="cancellationToken">Cancellation token to cancel execution.</param>
|
|||
/// <exception cref="ObjectDisposedException">
|
|||
/// If global session was already cancelled and thread killed, it's not possible to dispatch any actions again
|
|||
/// </exception>
|
|||
public Task<TResult> Dispatch<TResult>(Func<Task<TResult>> action, CancellationToken cancellationToken) |
|||
{ |
|||
if (_cancellationTokenSource.IsCancellationRequested) |
|||
{ |
|||
throw new ObjectDisposedException("Session was already disposed."); |
|||
} |
|||
|
|||
var token = _cancellationTokenSource.Token; |
|||
|
|||
var tcs = new TaskCompletionSource<TResult>(); |
|||
_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); |
|||
|
|||
try |
|||
{ |
|||
var task = action(); |
|||
task.ContinueWith((_, s) => ((CancellationTokenSource)s!).Cancel(), cts, |
|||
TaskScheduler.FromCurrentSynchronizationContext()); |
|||
|
|||
if (cts.IsCancellationRequested) |
|||
{ |
|||
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(); |
|||
} |
|||
|
|||
/// <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>
|
|||
public static HeadlessUnitTestSession StartNew( |
|||
[DynamicallyAccessedMembers(DynamicallyAccessed)] |
|||
Type entryPointType) |
|||
{ |
|||
var tcs = new TaskCompletionSource<HeadlessUnitTestSession>(); |
|||
var cancellationTokenSource = new CancellationTokenSource(); |
|||
var queue = new BlockingCollection<Action>(); |
|||
|
|||
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(); |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Creates a session from AvaloniaTestApplicationAttribute attribute or reuses any existing.
|
|||
/// If AvaloniaTestApplicationAttribute doesn't exist, empty application is used.
|
|||
/// </summary>
|
|||
[UnconditionalSuppressMessage("Trimming", "IL2072", |
|||
Justification = "AvaloniaTestApplicationAttribute attribute should preserve type information.")] |
|||
public static HeadlessUnitTestSession GetOrStartForAssembly(Assembly? assembly) |
|||
{ |
|||
return s_session.GetOrAdd(assembly ?? typeof(HeadlessUnitTestSession).Assembly, a => |
|||
{ |
|||
var appBuilderEntryPointType = a.GetCustomAttribute<AvaloniaTestApplicationAttribute>() |
|||
?.AppBuilderEntryPointType; |
|||
return appBuilderEntryPointType is not null ? |
|||
StartNew(appBuilderEntryPointType) : |
|||
StartNew(typeof(Application)); |
|||
}); |
|||
} |
|||
} |
|||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue