Ver código fonte

Merge pull request #10473 from AvaloniaUI/xunit-ext

Avalonia.Headless.XUnit extensions and attempt to make Avalonia.Headless easier to use
Max Katz 2 anos atrás
pai
commit
67d68ae06d
32 arquivos alterados com 698 adições e 187 exclusões
  1. 20 2
      Avalonia.sln
  2. 1 0
      nukebuild/Build.cs
  3. 1 1
      samples/ControlCatalog.NetCore/ControlCatalog.NetCore.csproj
  4. 5 1
      samples/ControlCatalog.NetCore/Properties/launchSettings.json
  5. 1 1
      samples/MobileSandbox.Desktop/MobileSandbox.Desktop.csproj
  6. 27 0
      src/Avalonia.Base/Platform/Storage/NoopStorageProvider.cs
  7. 38 1
      src/Avalonia.Controls/AppBuilder.cs
  8. 6 0
      src/Avalonia.Controls/Avalonia.Controls.csproj
  9. 2 1
      src/Avalonia.Controls/Platform/ScreenHelper.cs
  10. 4 1
      src/Avalonia.Controls/TopLevel.cs
  11. 2 10
      src/Avalonia.DesignerSupport/Remote/RemoteDesignerEntryPoint.cs
  12. 0 13
      src/Avalonia.Headless/Avalonia.Headless.csproj
  13. 0 4
      src/Avalonia.Native/Avalonia.Native.csproj
  14. 5 1
      src/Headless/Avalonia.Headless.Vnc/Avalonia.Headless.Vnc.csproj
  15. 17 19
      src/Headless/Avalonia.Headless.Vnc/HeadlessVncFramebufferSource.cs
  16. 3 2
      src/Headless/Avalonia.Headless.Vnc/HeadlessVncPlatformExtensions.cs
  17. 19 0
      src/Headless/Avalonia.Headless.XUnit/Avalonia.Headless.XUnit.csproj
  18. 35 0
      src/Headless/Avalonia.Headless.XUnit/AvaloniaTestFramework.cs
  19. 45 0
      src/Headless/Avalonia.Headless.XUnit/AvaloniaTestFrameworkAttribute.cs
  20. 61 0
      src/Headless/Avalonia.Headless.XUnit/AvaloniaTestRunner.cs
  21. 18 0
      src/Headless/Avalonia.Headless/Avalonia.Headless.csproj
  22. 12 8
      src/Headless/Avalonia.Headless/AvaloniaHeadlessPlatform.cs
  23. 24 22
      src/Headless/Avalonia.Headless/HeadlessPlatformRenderInterface.cs
  24. 32 41
      src/Headless/Avalonia.Headless/HeadlessPlatformStubs.cs
  25. 101 0
      src/Headless/Avalonia.Headless/HeadlessWindowExtensions.cs
  26. 70 54
      src/Headless/Avalonia.Headless/HeadlessWindowImpl.cs
  27. 5 5
      src/Headless/Avalonia.Headless/IHeadlessWindow.cs
  28. 19 0
      tests/Avalonia.Headless.UnitTests/Avalonia.Headless.UnitTests.csproj
  29. 36 0
      tests/Avalonia.Headless.UnitTests/InputTests.cs
  30. 33 0
      tests/Avalonia.Headless.UnitTests/RenderingTests.cs
  31. 24 0
      tests/Avalonia.Headless.UnitTests/TestApplication.cs
  32. 32 0
      tests/Avalonia.Headless.UnitTests/ThreadingTests.cs

+ 20 - 2
Avalonia.sln

@@ -181,9 +181,9 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Avalonia.Controls.DataGrid.
 EndProject
 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Avalonia.Themes.Fluent", "src\Avalonia.Themes.Fluent\Avalonia.Themes.Fluent.csproj", "{C42D2FC1-A531-4ED4-84B9-89AEC7C962FC}"
 EndProject
-Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Avalonia.Headless", "src\Avalonia.Headless\Avalonia.Headless.csproj", "{8C89950F-F5D9-47FC-8066-CBC1EC3DF8FC}"
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Avalonia.Headless", "src\Headless\Avalonia.Headless\Avalonia.Headless.csproj", "{8C89950F-F5D9-47FC-8066-CBC1EC3DF8FC}"
 EndProject
-Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Avalonia.Headless.Vnc", "src\Avalonia.Headless.Vnc\Avalonia.Headless.Vnc.csproj", "{B859AE7C-F34F-4A9E-88AE-E0E7229FDE1E}"
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Avalonia.Headless.Vnc", "src\Headless\Avalonia.Headless.Vnc\Avalonia.Headless.Vnc.csproj", "{B859AE7C-F34F-4A9E-88AE-E0E7229FDE1E}"
 EndProject
 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Avalonia.Markup.Xaml.Loader", "src\Markup\Avalonia.Markup.Xaml.Loader\Avalonia.Markup.Xaml.Loader.csproj", "{909A8CBD-7D0E-42FD-B841-022AD8925820}"
 EndProject
@@ -260,6 +260,12 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SafeAreaDemo.Desktop", "sam
 EndProject
 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SafeAreaDemo.iOS", "samples\SafeAreaDemo.iOS\SafeAreaDemo.iOS.csproj", "{FC956F9A-4C3A-4A1A-ACDD-BB54DCB661DD}"
 EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Headless", "Headless", "{FF237916-7150-496B-89ED-6CA3292896E7}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Avalonia.Headless.XUnit", "src\Headless\Avalonia.Headless.XUnit\Avalonia.Headless.XUnit.csproj", "{F47F8316-4D4B-4026-8EF3-16B2CFDA8119}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Avalonia.Headless.UnitTests", "tests\Avalonia.Headless.UnitTests\Avalonia.Headless.UnitTests.csproj", "{3B2405E8-9E7A-46D1-8E2D-EF9ED124C9F2}"
+EndProject
 Global
 	GlobalSection(SolutionConfigurationPlatforms) = preSolution
 		Debug|Any CPU = Debug|Any CPU
@@ -599,6 +605,14 @@ Global
 		{13F1135D-BA1A-435C-9C5B-A368D1D63DE4}.Debug|Any CPU.Build.0 = Debug|Any CPU
 		{13F1135D-BA1A-435C-9C5B-A368D1D63DE4}.Release|Any CPU.ActiveCfg = Release|Any CPU
 		{13F1135D-BA1A-435C-9C5B-A368D1D63DE4}.Release|Any CPU.Build.0 = Release|Any CPU
+		{F47F8316-4D4B-4026-8EF3-16B2CFDA8119}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{F47F8316-4D4B-4026-8EF3-16B2CFDA8119}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{F47F8316-4D4B-4026-8EF3-16B2CFDA8119}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{F47F8316-4D4B-4026-8EF3-16B2CFDA8119}.Release|Any CPU.Build.0 = Release|Any CPU
+		{3B2405E8-9E7A-46D1-8E2D-EF9ED124C9F2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{3B2405E8-9E7A-46D1-8E2D-EF9ED124C9F2}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{3B2405E8-9E7A-46D1-8E2D-EF9ED124C9F2}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{3B2405E8-9E7A-46D1-8E2D-EF9ED124C9F2}.Release|Any CPU.Build.0 = Release|Any CPU
 		{A82AD1BC-EBE6-4FC3-A13B-D52A50297533}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
 		{A82AD1BC-EBE6-4FC3-A13B-D52A50297533}.Debug|Any CPU.Build.0 = Debug|Any CPU
 		{A82AD1BC-EBE6-4FC3-A13B-D52A50297533}.Release|Any CPU.ActiveCfg = Release|Any CPU
@@ -690,6 +704,10 @@ Global
 		{C810060E-3809-4B74-A125-F11533AF9C1B} = {9B9E3891-2366-4253-A952-D08BCEB71098}
 		{C692FE73-43DB-49CE-87FC-F03ED61F25C9} = {4ED8B739-6F4E-4CD4-B993-545E6B5CE637}
 		{F4E36AA8-814E-4704-BC07-291F70F45193} = {C5A00AC3-B34C-4564-9BDD-2DA473EF4D8B}
+		{8C89950F-F5D9-47FC-8066-CBC1EC3DF8FC} = {FF237916-7150-496B-89ED-6CA3292896E7}
+		{B859AE7C-F34F-4A9E-88AE-E0E7229FDE1E} = {FF237916-7150-496B-89ED-6CA3292896E7}
+		{F47F8316-4D4B-4026-8EF3-16B2CFDA8119} = {FF237916-7150-496B-89ED-6CA3292896E7}
+		{3B2405E8-9E7A-46D1-8E2D-EF9ED124C9F2} = {C5A00AC3-B34C-4564-9BDD-2DA473EF4D8B}
 		{DDA28789-C21A-4654-86CE-D01E81F095C5} = {4ED8B739-6F4E-4CD4-B993-545E6B5CE637}
 		{2D7C812B-7E73-4252-8EFD-BC8A4D5CCB9F} = {C5A00AC3-B34C-4564-9BDD-2DA473EF4D8B}
 		{A82AD1BC-EBE6-4FC3-A13B-D52A50297533} = {9B9E3891-2366-4253-A952-D08BCEB71098}

+ 1 - 0
nukebuild/Build.cs

@@ -212,6 +212,7 @@ partial class Build : NukeBuild
             RunCoreTest("Avalonia.Markup.Xaml.UnitTests");
             RunCoreTest("Avalonia.Skia.UnitTests");
             RunCoreTest("Avalonia.ReactiveUI.UnitTests");
+            RunCoreTest("Avalonia.Headless.UnitTests");
         });
 
     Target RunRenderTests => _ => _

+ 1 - 1
samples/ControlCatalog.NetCore/ControlCatalog.NetCore.csproj

@@ -26,7 +26,7 @@
 
   <ItemGroup>
     <ProjectReference Include="..\..\src\Avalonia.Diagnostics\Avalonia.Diagnostics.csproj" />
-    <ProjectReference Include="..\..\src\Avalonia.Headless.Vnc\Avalonia.Headless.Vnc.csproj" />
+    <ProjectReference Include="..\..\src\Headless\Avalonia.Headless.Vnc\Avalonia.Headless.Vnc.csproj" />
     <ProjectReference Include="..\..\src\Avalonia.Dialogs\Avalonia.Dialogs.csproj" />
     <ProjectReference Include="..\..\src\Linux\Avalonia.LinuxFramebuffer\Avalonia.LinuxFramebuffer.csproj" />
     <ProjectReference Include="..\ControlCatalog\ControlCatalog.csproj" />

+ 5 - 1
samples/ControlCatalog.NetCore/Properties/launchSettings.json

@@ -6,6 +6,10 @@
     "Dxgi": {
       "commandName": "Project",
       "commandLineArgs": "--dxgi"
+    },
+    "VNC": {
+      "commandName": "Project",
+      "commandLineArgs": "--vnc"
     }
   }
-}
+}

+ 1 - 1
samples/MobileSandbox.Desktop/MobileSandbox.Desktop.csproj

@@ -19,7 +19,7 @@
 
   <ItemGroup>
     <ProjectReference Include="..\..\src\Avalonia.Diagnostics\Avalonia.Diagnostics.csproj" />
-    <ProjectReference Include="..\..\src\Avalonia.Headless.Vnc\Avalonia.Headless.Vnc.csproj" />
+    <ProjectReference Include="..\..\src\Headless\Avalonia.Headless.Vnc\Avalonia.Headless.Vnc.csproj" />
     <ProjectReference Include="..\..\src\Avalonia.Dialogs\Avalonia.Dialogs.csproj" />
     <ProjectReference Include="..\..\src\Linux\Avalonia.LinuxFramebuffer\Avalonia.LinuxFramebuffer.csproj" />
     <ProjectReference Include="..\MobileSandbox\MobileSandbox.csproj" />

+ 27 - 0
src/Avalonia.Base/Platform/Storage/NoopStorageProvider.cs

@@ -0,0 +1,27 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Avalonia.Platform.Storage.FileIO;
+
+namespace Avalonia.Platform.Storage;
+
+internal class NoopStorageProvider : BclStorageProvider
+{
+    public override bool CanOpen => false;
+    public override Task<IReadOnlyList<IStorageFile>> OpenFilePickerAsync(FilePickerOpenOptions options)
+    {
+        return Task.FromResult<IReadOnlyList<IStorageFile>>(Array.Empty<IStorageFile>());
+    }
+
+    public override bool CanSave => false;
+    public override Task<IStorageFile?> SaveFilePickerAsync(FilePickerSaveOptions options)
+    {
+        return Task.FromResult<IStorageFile?>(null);
+    }
+
+    public override bool CanPickFolder => false;
+    public override Task<IReadOnlyList<IStorageFolder>> OpenFolderPickerAsync(FolderPickerOpenOptions options)
+    {
+        return Task.FromResult<IReadOnlyList<IStorageFolder>>(Array.Empty<IStorageFolder>());
+    }
+}

+ 38 - 1
src/Avalonia.Controls/AppBuilder.cs

@@ -118,6 +118,43 @@ namespace Avalonia
             };
         }
 
+        /// <summary>
+        /// Begin configuring an <see cref="Application"/>.
+        /// Should only be used for testing and design purposes, as it relies on dynamic code.
+        /// </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>
+        /// <returns>An <see cref="AppBuilder"/> instance. If can't be created, thrown an exception.</returns>
+        internal static AppBuilder Configure(
+            [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods | DynamicallyAccessedMemberTypes.NonPublicMethods | DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)]
+            Type entryPointType)
+        {
+            var appBuilderObj = entryPointType
+                .GetMethod(
+                    "BuildAvaloniaApp",
+                    BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.FlattenHierarchy,
+                    null,
+                    Array.Empty<Type>(),
+                    null)?
+                .Invoke(null, Array.Empty<object?>());
+
+            if (appBuilderObj is AppBuilder appBuilder)
+            {
+                return appBuilder;
+            }
+
+            if (typeof(Application).IsAssignableFrom(entryPointType))
+            {
+                return Configure(() => (Application)Activator.CreateInstance(entryPointType)!);
+            }
+
+            throw new InvalidOperationException(
+                $"Unable to create AppBuilder from type {entryPointType.Name}." +
+                $"Input type either needs to have BuildAvaloniaApp -> AppBuilder method or inherit Application type.");
+        }
+        
         protected AppBuilder Self => this;
 
         public AppBuilder AfterSetup(Action<AppBuilder> callback)
@@ -206,7 +243,7 @@ namespace Avalonia
             _optionsInitializers += () => { AvaloniaLocator.CurrentMutable.Bind<T>().ToFunc(options); };
             return Self;
         }
-        
+
         /// <summary>
         /// Registers an action that is executed with the current font manager.
         /// </summary>

+ 6 - 0
src/Avalonia.Controls/Avalonia.Controls.csproj

@@ -16,5 +16,11 @@
     <InternalsVisibleTo Include="Avalonia.Controls.UnitTests, PublicKey=$(AvaloniaPublicKey)" />
     <InternalsVisibleTo Include="Avalonia.DesignerSupport, PublicKey=$(AvaloniaPublicKey)" />
     <InternalsVisibleTo Include="Avalonia.LeakTests, PublicKey=$(AvaloniaPublicKey)" />
+    <InternalsVisibleTo Include="Avalonia.Headless, PublicKey=$(AvaloniaPublicKey)" />
+    <InternalsVisibleTo Include="Avalonia.Headless.XUnit, PublicKey=$(AvaloniaPublicKey)" />
+    <InternalsVisibleTo Include="Avalonia.Native, PublicKey=$(AvaloniaPublicKey)" />
+    <InternalsVisibleTo Include="Avalonia.X11, PublicKey=$(AvaloniaPublicKey)" />
+    <InternalsVisibleTo Include="Avalonia.DesignerSupport.Remote, PublicKey=$(AvaloniaPublicKey)" />
+    <InternalsVisibleTo Include="Avalonia.Browser, PublicKey=$(AvaloniaPublicKey)" />
   </ItemGroup>
 </Project>

+ 2 - 1
src/Avalonia.Controls/Platform/ScreenHelper.cs

@@ -1,11 +1,12 @@
 using System.Collections.Generic;
+using Avalonia.Controls;
 using Avalonia.Utilities;
 
 #nullable enable
 
 namespace Avalonia.Platform
 {
-    public static class ScreenHelper
+    internal static class ScreenHelper
     {
         public static Screen? ScreenFromPoint(PixelPoint point, IReadOnlyList<Screen> screens)
         {

+ 4 - 1
src/Avalonia.Controls/TopLevel.cs

@@ -432,10 +432,13 @@ namespace Avalonia.Controls
 
         IStyleHost IStyleHost.StylingParent => _globalStyles!;
         
+        /// <summary>
+        /// File System storage service used for file pickers and bookmarks.
+        /// </summary>
         public IStorageProvider StorageProvider => _storageProvider
             ??= AvaloniaLocator.Current.GetService<IStorageProviderFactory>()?.CreateProvider(this)
             ?? PlatformImpl?.TryGetFeature<IStorageProvider>()
-            ?? throw new InvalidOperationException("StorageProvider platform implementation is not available.");
+            ?? new NoopStorageProvider();
 
         public IInsetsManager? InsetsManager => PlatformImpl?.TryGetFeature<IInsetsManager>();
 

+ 2 - 10
src/Avalonia.DesignerSupport/Remote/RemoteDesignerEntryPoint.cs

@@ -179,17 +179,9 @@ namespace Avalonia.DesignerSupport.Remote
             var entryPoint = asm.EntryPoint;
             if (entryPoint == null)
                 throw Die($"Assembly {args.AppPath} doesn't have an entry point");
-            var builderMethod = entryPoint.DeclaringType.GetMethod(
-                BuilderMethodName,
-                BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.FlattenHierarchy,
-                null,
-                Array.Empty<Type>(),
-                null);
-            if (builderMethod == null)
-                throw Die($"{entryPoint.DeclaringType.FullName} doesn't have a method named {BuilderMethodName}");
+            Log($"Obtaining AppBuilder instance from {entryPoint.DeclaringType!.FullName}");
+            var appBuilder = AppBuilder.Configure(entryPoint.DeclaringType);
             Design.IsDesignMode = true;
-            Log($"Obtaining AppBuilder instance from {builderMethod.DeclaringType.FullName}.{builderMethod.Name}");
-            var appBuilder = builderMethod.Invoke(null, null);
             Log($"Initializing application in design mode");
             var initializer =(IAppInitializer)Activator.CreateInstance(typeof(AppInitializer));
             transport = initializer.ConfigureApp(transport, args, appBuilder);

+ 0 - 13
src/Avalonia.Headless/Avalonia.Headless.csproj

@@ -1,13 +0,0 @@
-<Project Sdk="Microsoft.NET.Sdk">
-  <PropertyGroup>
-    <TargetFrameworks>net6.0;netstandard2.0</TargetFrameworks>
-
-  </PropertyGroup>
-  <ItemGroup>
-    <ProjectReference Include="..\..\packages\Avalonia\Avalonia.csproj" />
-  </ItemGroup>
-
-  <Import Project="..\..\build\ApiDiff.props" />
-  <Import Project="..\..\build\DevAnalyzers.props" />
-  <Import Project="..\..\build\TrimmingEnable.props" />
-</Project>

+ 0 - 4
src/Avalonia.Native/Avalonia.Native.csproj

@@ -26,8 +26,4 @@
 
   <Import Project="..\..\build\DevAnalyzers.props" />
   <Import Project="..\..\build\TrimmingEnable.props" />
-
-  <ItemGroup>
-    <Compile Remove="..\Shared\ModuleInitializer.cs" />
-  </ItemGroup>
 </Project>

+ 5 - 1
src/Avalonia.Headless.Vnc/Avalonia.Headless.Vnc.csproj → src/Headless/Avalonia.Headless.Vnc/Avalonia.Headless.Vnc.csproj

@@ -2,6 +2,7 @@
 
     <PropertyGroup>
       <TargetFrameworks>net6.0;netstandard2.0</TargetFrameworks>
+      <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
     </PropertyGroup>
 
     <ItemGroup>
@@ -9,5 +10,8 @@
       <PackageReference Include="Quamotion.RemoteViewing" Version="1.1.21" />
     </ItemGroup>
 
-    <Import Project="..\..\build\TrimmingEnable.props" />
+    <Import Project="..\..\..\build\ApiDiff.props" />
+    <Import Project="..\..\..\build\DevAnalyzers.props" />
+    <Import Project="..\..\..\build\TrimmingEnable.props" />
+    <Import Project="..\..\..\build\NullableEnable.props" />
 </Project>

+ 17 - 19
src/Avalonia.Headless.Vnc/HeadlessVncFramebufferSource.cs → src/Headless/Avalonia.Headless.Vnc/HeadlessVncFramebufferSource.cs

@@ -2,6 +2,7 @@
 using System.Runtime.InteropServices;
 using Avalonia.Controls;
 using Avalonia.Input;
+using Avalonia.Platform;
 using Avalonia.Threading;
 using RemoteViewing.Vnc;
 using RemoteViewing.Vnc.Server;
@@ -10,22 +11,28 @@ namespace Avalonia.Headless.Vnc
 {
     public class HeadlessVncFramebufferSource : IVncFramebufferSource
     {
-        public IHeadlessWindow Window { get; set; }
+        public Window Window { get; set; }
         private object _lock = new object();
         public VncFramebuffer _framebuffer = new VncFramebuffer("Avalonia", 1, 1, VncPixelFormat.RGB32);
 
         private VncButton _previousButtons;
         public HeadlessVncFramebufferSource(VncServerSession session, Window window)
         {
-            Window = (IHeadlessWindow)window.PlatformImpl;
+            Window = window;
             session.PointerChanged += (_, args) =>
             {
                 var pt = new Point(args.X, args.Y);
                     
                 var buttons = (VncButton)args.PressedButtons;
 
-                int TranslateButton(VncButton vncButton) =>
-                    vncButton == VncButton.Left ? 0 : vncButton == VncButton.Right ? 1 : 2;
+                MouseButton TranslateButton(VncButton vncButton) =>
+                    vncButton switch
+                    {
+                        VncButton.Left => MouseButton.Left,
+                        VncButton.Middle => MouseButton.Middle,
+                        VncButton.Right => MouseButton.Right,
+                        _ => MouseButton.None
+                    };
 
                 var modifiers = (RawInputModifiers)(((int)buttons & 7) << 4);
                 
@@ -58,34 +65,25 @@ namespace Avalonia.Headless.Vnc
 
         private static VncButton[] CheckedButtons = new[] {VncButton.Left, VncButton.Middle, VncButton.Right}; 
 
-        public VncFramebuffer Capture()
+        public unsafe VncFramebuffer Capture()
         {
             lock (_lock)
             {
                 using (var bmpRef = Window.GetLastRenderedFrame())
                 {
-                    if (bmpRef?.Item == null)
+                    if (bmpRef == null)
                         return _framebuffer;
-                    var bmp = bmpRef.Item;
+                    var bmp = bmpRef;
                     if (bmp.PixelSize.Width != _framebuffer.Width || bmp.PixelSize.Height != _framebuffer.Height)
                     {
                         _framebuffer = new VncFramebuffer("Avalonia", bmp.PixelSize.Width, bmp.PixelSize.Height,
                             VncPixelFormat.RGB32);
                     }
 
-                    using (var fb = bmp.Lock())
+                    var buffer = _framebuffer.GetBuffer();
+                    fixed (byte* bufferPtr = buffer)
                     {
-                        var buf = _framebuffer.GetBuffer();
-                        if (_framebuffer.Stride == fb.RowBytes)
-                            Marshal.Copy(fb.Address, buf, 0, buf.Length);
-                        else
-                            for (var y = 0; y < fb.Size.Height; y++)
-                            {
-                                var sourceStart = fb.RowBytes * y;
-                                var dstStart = _framebuffer.Stride * y;
-                                var row = fb.Size.Width * 4;
-                                Marshal.Copy(new IntPtr(sourceStart + fb.Address.ToInt64()), buf, dstStart, row);
-                            }
+                        bmp.CopyPixels(new PixelRect(default, bmp.PixelSize), (IntPtr)bufferPtr, buffer.Length, _framebuffer.Stride);
                     }
                 }
             }

+ 3 - 2
src/Avalonia.Headless.Vnc/HeadlessVncPlatformExtensions.cs → src/Headless/Avalonia.Headless.Vnc/HeadlessVncPlatformExtensions.cs

@@ -1,3 +1,4 @@
+using System;
 using System.Net;
 using System.Net.Sockets;
 using Avalonia.Controls;
@@ -25,7 +26,7 @@ namespace Avalonia
                 })
                 .AfterSetup(_ =>
                 {
-                    var lt = ((IClassicDesktopStyleApplicationLifetime)builder.Instance.ApplicationLifetime);
+                    var lt = ((IClassicDesktopStyleApplicationLifetime)builder.Instance!.ApplicationLifetime!);
                     lt.Startup += async delegate
                     {
                         while (true)
@@ -38,7 +39,7 @@ namespace Avalonia
                             var session = new VncServerSession();
                             
                             session.SetFramebufferSource(new HeadlessVncFramebufferSource(
-                                session, lt.MainWindow));
+                                session, lt.MainWindow ?? throw new InvalidOperationException("MainWindow wasn't initialized")));
                             session.Connect(client.GetStream(), options);
                         }
                         

+ 19 - 0
src/Headless/Avalonia.Headless.XUnit/Avalonia.Headless.XUnit.csproj

@@ -0,0 +1,19 @@
+<Project Sdk="Microsoft.NET.Sdk">
+  <PropertyGroup>
+    <TargetFramework>net6.0</TargetFramework>
+    <ImplicitUsings>enable</ImplicitUsings>
+    <Nullable>enable</Nullable>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <PackageReference Include="xunit.core" Version="2.4.2" />
+  </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>

+ 35 - 0
src/Headless/Avalonia.Headless.XUnit/AvaloniaTestFramework.cs

@@ -0,0 +1,35 @@
+using System.Reflection;
+using Xunit.Abstractions;
+using Xunit.Sdk;
+
+namespace Avalonia.Headless.XUnit;
+
+internal class AvaloniaTestFramework<TAppBuilderEntry> : XunitTestFramework
+{
+    public AvaloniaTestFramework(IMessageSink messageSink) : base(messageSink)
+    {
+    }
+
+    protected override ITestFrameworkExecutor CreateExecutor(AssemblyName assemblyName)
+        => new Executor(assemblyName, SourceInformationProvider, DiagnosticMessageSink);
+
+
+    private class Executor : XunitTestFrameworkExecutor
+    {
+        public Executor(AssemblyName assemblyName, ISourceInformationProvider sourceInformationProvider,
+            IMessageSink diagnosticMessageSink) : base(assemblyName, sourceInformationProvider,
+            diagnosticMessageSink)
+        {
+        }
+
+        protected override async void RunTestCases(IEnumerable<IXunitTestCase> testCases,
+            IMessageSink executionMessageSink,
+            ITestFrameworkExecutionOptions executionOptions)
+        {
+            executionOptions.SetValue("xunit.execution.DisableParallelization", false);
+            using (var assemblyRunner = new AvaloniaTestRunner<TAppBuilderEntry>(
+                       TestAssembly, testCases, DiagnosticMessageSink, executionMessageSink,
+                       executionOptions)) await assemblyRunner.RunAsync();
+        }
+    }
+}

+ 45 - 0
src/Headless/Avalonia.Headless.XUnit/AvaloniaTestFrameworkAttribute.cs

@@ -0,0 +1,45 @@
+using System.Diagnostics.CodeAnalysis;
+using Xunit.Abstractions;
+using Xunit.Sdk;
+
+namespace Avalonia.Headless.XUnit;
+
+/// <summary>
+/// Sets up global avalonia test framework using avalonia application builder passed as a parameter.
+/// </summary>
+[TestFrameworkDiscoverer("Avalonia.Headless.XUnit.AvaloniaTestFrameworkTypeDiscoverer", "Avalonia.Headless.XUnit")]
+[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = false)]
+public sealed class AvaloniaTestFrameworkAttribute : Attribute, ITestFrameworkAttribute
+{
+    /// <summary>
+    /// Creates instance of <see cref="AvaloniaTestFrameworkAttribute"/>. 
+    /// </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 AvaloniaTestFrameworkAttribute(
+        [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods | DynamicallyAccessedMemberTypes.NonPublicMethods | DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)]
+        Type appBuilderEntryPointType) { }
+}
+
+/// <summary>
+/// Discoverer implementation for the Avalonia testing framework.
+/// </summary>
+public class AvaloniaTestFrameworkTypeDiscoverer : ITestFrameworkTypeDiscoverer
+{
+    /// <summary>
+    /// Creates instance of <see cref="AvaloniaTestFrameworkTypeDiscoverer"/>. 
+    /// </summary>
+    public AvaloniaTestFrameworkTypeDiscoverer(IMessageSink _)
+    {
+    }
+
+    /// <inheritdoc/>
+    public Type GetTestFrameworkType(IAttributeInfo attribute)
+    {
+        var builderType = attribute.GetConstructorArguments().First() as Type
+            ?? throw new InvalidOperationException("AppBuilderEntryPointType parameter must be defined on the AvaloniaTestFrameworkAttribute attribute.");
+        return typeof(AvaloniaTestFramework<>).MakeGenericType(builderType);
+    }
+}

+ 61 - 0
src/Headless/Avalonia.Headless.XUnit/AvaloniaTestRunner.cs

@@ -0,0 +1,61 @@
+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;
+    }
+}

+ 18 - 0
src/Headless/Avalonia.Headless/Avalonia.Headless.csproj

@@ -0,0 +1,18 @@
+<Project Sdk="Microsoft.NET.Sdk">
+  <PropertyGroup>
+    <TargetFrameworks>net6.0;netstandard2.0</TargetFrameworks>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <ProjectReference Include="..\..\..\packages\Avalonia\Avalonia.csproj" />
+  </ItemGroup>
+
+  <Import Project="..\..\..\build\ApiDiff.props" />
+  <Import Project="..\..\..\build\DevAnalyzers.props" />
+  <Import Project="..\..\..\build\TrimmingEnable.props" />
+  <Import Project="..\..\..\build\NullableEnable.props" />
+
+  <ItemGroup Label="InternalsVisibleTo">
+    <InternalsVisibleTo Include="Avalonia.Headless.Vnc, PublicKey=$(AvaloniaPublicKey)" />
+  </ItemGroup>
+</Project>

+ 12 - 8
src/Avalonia.Headless/AvaloniaHeadlessPlatform.cs → src/Headless/Avalonia.Headless/AvaloniaHeadlessPlatform.cs

@@ -1,8 +1,7 @@
 using System;
 using System.Diagnostics;
-using Avalonia.Reactive;
-using Avalonia.Controls;
 using Avalonia.Controls.Platform;
+using Avalonia.Reactive;
 using Avalonia.Input;
 using Avalonia.Input.Platform;
 using Avalonia.Platform;
@@ -14,11 +13,12 @@ namespace Avalonia.Headless
 {
     public static class AvaloniaHeadlessPlatform
     {
-        internal static Compositor Compositor { get; private set; }
-        class RenderTimer : DefaultRenderTimer
+        internal static Compositor? Compositor { get; private set; }
+
+        private class RenderTimer : DefaultRenderTimer
         {
             private readonly int _framesPerSecond;
-            private Action _forceTick; 
+            private Action? _forceTick; 
             protected override IDisposable StartCore(Action<TimeSpan> tick)
             {
                 bool cancelled = false;
@@ -48,7 +48,7 @@ namespace Avalonia.Headless
             public void ForceTick() => _forceTick?.Invoke();
         }
 
-        class HeadlessWindowingPlatform : IWindowingPlatform
+        private class HeadlessWindowingPlatform : IWindowingPlatform
         {
             public IWindowImpl CreateWindow() => new HeadlessWindowImpl(false);
 
@@ -56,7 +56,7 @@ namespace Avalonia.Headless
 
             public IPopupImpl CreatePopup() => new HeadlessWindowImpl(true);
 
-            public ITrayIconImpl CreateTrayIcon() => null;
+            public ITrayIconImpl? CreateTrayIcon() => null;
         }
         
         internal static void Initialize(AvaloniaHeadlessPlatformOptions opts)
@@ -75,7 +75,11 @@ namespace Avalonia.Headless
             Compositor = new Compositor(AvaloniaLocator.Current.GetRequiredService<IRenderLoop>(), null);
         }
 
-
+        /// <summary>
+        /// Forces renderer to process a rendering timer tick.
+        /// Use this method before calling <see cref="HeadlessWindowExtensions.GetLastRenderedFrame"/>. 
+        /// </summary>
+        /// <param name="count">Count of frames to be ticked on the timer.</param>
         public static void ForceRenderTimerTick(int count = 1)
         {
             var timer = AvaloniaLocator.Current.GetService<IRenderTimer>() as RenderTimer;

+ 24 - 22
src/Avalonia.Headless/HeadlessPlatformRenderInterface.cs → src/Headless/Avalonia.Headless/HeadlessPlatformRenderInterface.cs

@@ -1,5 +1,6 @@
 using System;
 using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
 using System.IO;
 using System.Numerics;
 using System.Runtime.InteropServices;
@@ -18,12 +19,13 @@ namespace Avalonia.Headless
         public static void Initialize()
         {
             AvaloniaLocator.CurrentMutable
-                .Bind<IPlatformRenderInterface>().ToConstant(new HeadlessPlatformRenderInterface());
+                .Bind<IPlatformRenderInterface>().ToConstant(new HeadlessPlatformRenderInterface())
+                .Bind<IFontManagerImpl>().ToConstant(new HeadlessFontManagerStub());
         }
 
         public IEnumerable<string> InstalledFontNames { get; } = new[] { "Tahoma" };
 
-        public IPlatformRenderInterfaceContext CreateBackendContext(IPlatformGraphicsContext graphicsContext) => this;
+        public IPlatformRenderInterfaceContext CreateBackendContext(IPlatformGraphicsContext? graphicsContext) => this;
 
         public bool SupportsIndividualRoundRects => false;
 
@@ -52,7 +54,7 @@ namespace Avalonia.Headless
 
         public IRenderTarget CreateRenderTarget(IEnumerable<object> surfaces) => new HeadlessRenderTarget();
         public bool IsLost => false;
-        public object TryGetFeature(Type featureType) => null;
+        public object? TryGetFeature(Type featureType) => null;
 
         public IRenderTargetBitmapImpl CreateRenderTargetBitmap(PixelSize size, Vector dpi)
         {
@@ -130,7 +132,7 @@ namespace Avalonia.Headless
             return new HeadlessGlyphRunStub();
         }
 
-        class HeadlessGlyphRunStub : IGlyphRunImpl
+        private class HeadlessGlyphRunStub : IGlyphRunImpl
         {
             public Rect Bounds => new Rect(new Size(8, 12));
 
@@ -144,7 +146,7 @@ namespace Avalonia.Headless
                 => Array.Empty<float>();
         }
 
-        class HeadlessGeometryStub : IGeometryImpl
+        private class HeadlessGeometryStub : IGeometryImpl
         {
             public HeadlessGeometryStub(Rect bounds)
             {
@@ -157,7 +159,7 @@ namespace Avalonia.Headless
             
             public virtual bool FillContains(Point point) => Bounds.Contains(point);
 
-            public Rect GetRenderBounds(IPen pen)
+            public Rect GetRenderBounds(IPen? pen)
             {
                 if(pen is null)
                 {
@@ -167,7 +169,7 @@ namespace Avalonia.Headless
                 return Bounds.Inflate(pen.Thickness / 2);
             }
 
-            public bool StrokeContains(IPen pen, Point point)
+            public bool StrokeContains(IPen? pen, Point point)
             {
                 return false;
             }
@@ -191,21 +193,21 @@ namespace Avalonia.Headless
                 return false;
             }
 
-            public bool TryGetSegment(double startDistance, double stopDistance, bool startOnBeginFigure, out IGeometryImpl segmentGeometry)
+            public bool TryGetSegment(double startDistance, double stopDistance, bool startOnBeginFigure, [NotNullWhen(true)] out IGeometryImpl? segmentGeometry)
             {
                 segmentGeometry = null;
                 return false;
             }
         }
 
-        class HeadlessTransformedGeometryStub : HeadlessGeometryStub, ITransformedGeometryImpl
+        private class HeadlessTransformedGeometryStub : HeadlessGeometryStub, ITransformedGeometryImpl
         {
             public HeadlessTransformedGeometryStub(IGeometryImpl b, Matrix transform) : this(Fix(b, transform))
             {
 
             }
 
-            static (IGeometryImpl, Matrix, Rect) Fix(IGeometryImpl b, Matrix transform)
+            private static (IGeometryImpl, Matrix, Rect) Fix(IGeometryImpl b, Matrix transform)
             {
                 if (b is HeadlessTransformedGeometryStub transformed)
                 {
@@ -227,7 +229,7 @@ namespace Avalonia.Headless
             public Matrix Transform { get; }
         }
 
-        class HeadlessStreamingGeometryStub : HeadlessGeometryStub, IStreamGeometryImpl
+        private class HeadlessStreamingGeometryStub : HeadlessGeometryStub, IStreamGeometryImpl
         {
             public HeadlessStreamingGeometryStub() : base(default)
             {
@@ -243,7 +245,7 @@ namespace Avalonia.Headless
                 return new HeadlessStreamingGeometryContextStub(this);
             }
 
-            class HeadlessStreamingGeometryContextStub : IStreamGeometryContextImpl
+            private class HeadlessStreamingGeometryContextStub : IStreamGeometryContextImpl
             {
                 private readonly HeadlessStreamingGeometryStub _parent;
                 private double _x1, _y1, _x2, _y2;
@@ -252,7 +254,7 @@ namespace Avalonia.Headless
                     _parent = parent;
                 }
 
-                void Track(Point pt)
+                private void Track(Point pt)
                 {
                     if (_x1 > pt.X)
                         _x1 = pt.X;
@@ -301,7 +303,7 @@ namespace Avalonia.Headless
             }
         }
 
-        class HeadlessBitmapStub : IBitmapImpl, IDrawingContextLayerImpl, IWriteableBitmapImpl
+        private class HeadlessBitmapStub : IBitmapImpl, IDrawingContextLayerImpl, IWriteableBitmapImpl
         {
             public Size Size { get; }
 
@@ -363,7 +365,7 @@ namespace Avalonia.Headless
             }
         }
 
-        class HeadlessDrawingContextStub : IDrawingContextImpl
+        private class HeadlessDrawingContextStub : IDrawingContextImpl
         {
             public void Dispose()
             {
@@ -437,16 +439,16 @@ namespace Avalonia.Headless
 
             }
 
-            public object GetFeature(Type t)
+            public object? GetFeature(Type t)
             {
                 return null;
             }
 
-            public void DrawLine(IPen pen, Point p1, Point p2)
+            public void DrawLine(IPen? pen, Point p1, Point p2)
             {
             }
 
-            public void DrawGeometry(IBrush brush, IPen pen, IGeometryImpl geometry)
+            public void DrawGeometry(IBrush? brush, IPen? pen, IGeometryImpl geometry)
             {
             }
 
@@ -464,16 +466,16 @@ namespace Avalonia.Headless
                 
             }
 
-            public void DrawRectangle(IBrush brush, IPen pen, RoundedRect rect, BoxShadows boxShadow = default)
+            public void DrawRectangle(IBrush? brush, IPen? pen, RoundedRect rect, BoxShadows boxShadow = default)
             {
                 
             }
 
-            public void DrawEllipse(IBrush brush, IPen pen, Rect rect)
+            public void DrawEllipse(IBrush? brush, IPen? pen, Rect rect)
             {
             }
 
-            public void DrawGlyphRun(IBrush foreground, IRef<IGlyphRunImpl> glyphRun)
+            public void DrawGlyphRun(IBrush? foreground, IRef<IGlyphRunImpl> glyphRun)
             {
                 
             }
@@ -484,7 +486,7 @@ namespace Avalonia.Headless
             }
         }
 
-        class HeadlessRenderTarget : IRenderTarget
+        private class HeadlessRenderTarget : IRenderTarget
         {
             public void Dispose()
             {

+ 32 - 41
src/Avalonia.Headless/HeadlessPlatformStubs.cs → src/Headless/Avalonia.Headless/HeadlessPlatformStubs.cs

@@ -18,17 +18,17 @@ using Avalonia.Utilities;
 
 namespace Avalonia.Headless
 {
-    class HeadlessClipboardStub : IClipboard
+    internal class HeadlessClipboardStub : IClipboard
     {
-        private string _text;
-        private IDataObject _data;
+        private string? _text;
+        private IDataObject? _data;
 
-        public Task<string> GetTextAsync()
+        public Task<string?> GetTextAsync()
         {
             return Task.Run(() => _text);
         }
 
-        public Task SetTextAsync(string text)
+        public Task SetTextAsync(string? text)
         {
             return Task.Run(() => _text = text);
         }
@@ -45,16 +45,29 @@ namespace Avalonia.Headless
 
         public Task<string[]> GetFormatsAsync()
         {
-            throw new NotImplementedException();
+            return Task.Run(() =>
+            {
+                if (_data is not null)
+                {
+                    return _data.GetDataFormats().ToArray();
+                }
+
+                if (_text is not null)
+                {
+                    return new[] { DataFormats.Text };
+                }
+
+                return Array.Empty<string>();
+            });
         }
 
-        public async Task<object> GetDataAsync(string format)
+        public async Task<object?> GetDataAsync(string format)
         {
             return await Task.Run(() => _data);
         }
     }
 
-    class HeadlessCursorFactoryStub : ICursorFactory
+    internal class HeadlessCursorFactoryStub : ICursorFactory
     {
         public ICursorImpl GetCursor(StandardCursorType cursorType) => new CursorStub();
         public ICursorImpl CreateCursor(IBitmapImpl cursor, PixelPoint hotSpot) => new CursorStub();
@@ -65,7 +78,7 @@ namespace Avalonia.Headless
         }
     }
 
-    class HeadlessGlyphTypefaceImpl : IGlyphTypeface
+    internal class HeadlessGlyphTypefaceImpl : IGlyphTypeface
     {
         public FontMetrics Metrics => new FontMetrics
         {
@@ -125,7 +138,7 @@ namespace Avalonia.Headless
 
         public bool TryGetTable(uint tag, out byte[] table)
         {
-            table = null;
+            table = null!;
             return false;
         }
 
@@ -141,7 +154,7 @@ namespace Avalonia.Headless
         }
     }
 
-    class HeadlessTextShaperStub : ITextShaperImpl
+    internal class HeadlessTextShaperStub : ITextShaperImpl
     {
         public ShapedBuffer ShapeText(ReadOnlyMemory<char> text, TextShaperOptions options)
         {
@@ -153,7 +166,7 @@ namespace Avalonia.Headless
         }
     }
 
-    class HeadlessFontManagerStub : IFontManagerImpl
+    internal class HeadlessFontManagerStub : IFontManagerImpl
     {
         public string GetDefaultFontFamilyName()
         {
@@ -179,17 +192,16 @@ namespace Avalonia.Headless
             return true;
         }
 
-        public bool TryMatchCharacter(int codepoint, FontStyle fontStyle, FontWeight fontWeight, FontStretch fontStretch, CultureInfo culture, out Typeface typeface)
+        public bool TryMatchCharacter(int codepoint, FontStyle fontStyle, FontWeight fontWeight, FontStretch fontStretch, CultureInfo? culture, out Typeface typeface)
         {
             typeface = new Typeface("Arial", fontStyle, fontWeight, fontStretch);
             return true;
         }
     }
 
-    class HeadlessIconLoaderStub : IPlatformIconLoader
+    internal class HeadlessIconLoaderStub : IPlatformIconLoader
     {
-
-        class IconStub : IWindowIconImpl
+        private class IconStub : IWindowIconImpl
         {
             public void Save(Stream outputStream)
             {
@@ -212,7 +224,7 @@ namespace Avalonia.Headless
         }
     }
 
-    class HeadlessScreensStub : IScreenImpl
+    internal class HeadlessScreensStub : IScreenImpl
     {
         public int ScreenCount { get; } = 1;
 
@@ -222,40 +234,19 @@ namespace Avalonia.Headless
                 new PixelRect(0, 0, 1920, 1280), true),
         };
 
-        public Screen ScreenFromPoint(PixelPoint point)
+        public Screen? ScreenFromPoint(PixelPoint point)
         {
             return ScreenHelper.ScreenFromPoint(point, AllScreens);
         }
 
-        public Screen ScreenFromRect(PixelRect rect)
+        public Screen? ScreenFromRect(PixelRect rect)
         {
             return ScreenHelper.ScreenFromRect(rect, AllScreens);
         }
 
-        public Screen ScreenFromWindow(IWindowBaseImpl window)
+        public Screen? ScreenFromWindow(IWindowBaseImpl window)
         {
             return ScreenHelper.ScreenFromWindow(window, AllScreens);
         }
     }
-
-    internal class NoopStorageProvider : BclStorageProvider
-    {
-        public override bool CanOpen => false;
-        public override Task<IReadOnlyList<IStorageFile>> OpenFilePickerAsync(FilePickerOpenOptions options)
-        {
-            return Task.FromResult<IReadOnlyList<IStorageFile>>(Array.Empty<IStorageFile>());
-        }
-
-        public override bool CanSave => false;
-        public override Task<IStorageFile> SaveFilePickerAsync(FilePickerSaveOptions options)
-        {
-            return Task.FromResult<IStorageFile>(null);
-        }
-
-        public override bool CanPickFolder => false;
-        public override Task<IReadOnlyList<IStorageFolder>> OpenFolderPickerAsync(FolderPickerOpenOptions options)
-        {
-            return Task.FromResult<IReadOnlyList<IStorageFolder>>(Array.Empty<IStorageFolder>());
-        }
-    }
 }

+ 101 - 0
src/Headless/Avalonia.Headless/HeadlessWindowExtensions.cs

@@ -0,0 +1,101 @@
+using System;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Input.Raw;
+using Avalonia.Media.Imaging;
+using Avalonia.Platform;
+using Avalonia.Threading;
+
+namespace Avalonia.Headless;
+
+/// <summary>
+/// Set of extension methods to simplify usage of Avalonia.Headless platform.
+/// </summary>
+public static class HeadlessWindowExtensions
+{
+    /// <summary>
+    /// Triggers a renderer timer tick and captures last rendered frame.
+    /// </summary>
+    /// <returns>Bitmap with last rendered frame. Null, if nothing was rendered.</returns>
+    public static Bitmap? CaptureRenderedFrame(this TopLevel topLevel)
+    {
+        Dispatcher.UIThread.RunJobs();
+        AvaloniaHeadlessPlatform.ForceRenderTimerTick();
+        return topLevel.GetLastRenderedFrame();
+    }
+
+    /// <summary>
+    /// Reads last rendered frame.
+    /// Note, in order to trigger rendering timer, call <see cref="AvaloniaHeadlessPlatform.ForceRenderTimerTick"/> method.  
+    /// </summary>
+    /// <returns>Bitmap with last rendered frame. Null, if nothing was rendered.</returns>
+    public static Bitmap? GetLastRenderedFrame(this TopLevel topLevel)
+    {
+        if (AvaloniaLocator.Current.GetService<IPlatformRenderInterface>() is HeadlessPlatformRenderInterface)
+        {
+            throw new NotSupportedException(
+                "To capture a rendered frame, make sure that headless application was initialized with '.UseSkia()' and disabled 'UseHeadlessDrawing' in the 'AvaloniaHeadlessPlatformOptions'.");
+        }
+
+        return GetImpl(topLevel).GetLastRenderedFrame();
+    }
+
+    /// <summary>
+    /// Simulates keyboard press on the headless window/toplevel.
+    /// </summary>
+    public static void KeyPress(this TopLevel topLevel, Key key, RawInputModifiers modifiers) =>
+        RunJobsAndGetImpl(topLevel).KeyPress(key, modifiers);
+
+    /// <summary>
+    /// Simulates keyboard release on the headless window/toplevel.
+    /// </summary>
+    public static void KeyRelease(this TopLevel topLevel, Key key, RawInputModifiers modifiers) =>
+        RunJobsAndGetImpl(topLevel).KeyRelease(key, modifiers);
+
+    /// <summary>
+    /// Simulates mouse down on the headless window/toplevel.
+    /// </summary>
+    public static void MouseDown(this TopLevel topLevel, Point point, MouseButton button,
+        RawInputModifiers modifiers = RawInputModifiers.None) =>
+        RunJobsAndGetImpl(topLevel).MouseDown(point, button, modifiers);
+
+    /// <summary>
+    /// Simulates mouse move on the headless window/toplevel.
+    /// </summary>
+    public static void MouseMove(this TopLevel topLevel, Point point,
+        RawInputModifiers modifiers = RawInputModifiers.None) =>
+        RunJobsAndGetImpl(topLevel).MouseMove(point, modifiers);
+
+    /// <summary>
+    /// Simulates mouse up on the headless window/toplevel.
+    /// </summary>
+    public static void MouseUp(this TopLevel topLevel, Point point, MouseButton button,
+        RawInputModifiers modifiers = RawInputModifiers.None) =>
+        RunJobsAndGetImpl(topLevel).MouseUp(point, button, modifiers);
+
+    /// <summary>
+    /// Simulates mouse wheel on the headless window/toplevel.
+    /// </summary>
+    public static void MouseWheel(this TopLevel topLevel, Point point, Vector delta,
+        RawInputModifiers modifiers = RawInputModifiers.None) =>
+        RunJobsAndGetImpl(topLevel).MouseWheel(point, delta, modifiers);
+
+    /// <summary>
+    /// Simulates drag'n'drop target on the headless window/toplevel.
+    /// </summary>
+    public static void DragDrop(this TopLevel topLevel, Point point, RawDragEventType type, IDataObject data,
+        DragDropEffects effects, RawInputModifiers modifiers = RawInputModifiers.None) =>
+        RunJobsAndGetImpl(topLevel).DragDrop(point, type, data, effects, modifiers);
+
+    private static IHeadlessWindow RunJobsAndGetImpl(this TopLevel topLevel)
+    {
+        Dispatcher.UIThread.RunJobs();
+        return GetImpl(topLevel);
+    }
+
+    private static IHeadlessWindow GetImpl(this TopLevel topLevel)
+    {
+        return topLevel.PlatformImpl as IHeadlessWindow ??
+               throw new InvalidOperationException("TopLevel must be a headless window.");
+    }
+}

+ 70 - 54
src/Avalonia.Headless/HeadlessWindowImpl.cs → src/Headless/Avalonia.Headless/HeadlessWindowImpl.cs

@@ -17,20 +17,20 @@ using Avalonia.Utilities;
 
 namespace Avalonia.Headless
 {
-    class HeadlessWindowImpl : IWindowImpl, IPopupImpl, IFramebufferPlatformSurface, IHeadlessWindow
+    internal class HeadlessWindowImpl : IWindowImpl, IPopupImpl, IFramebufferPlatformSurface, IHeadlessWindow
     {
-        private IKeyboardDevice _keyboard;
-        private Stopwatch _st = Stopwatch.StartNew();
-        private Pointer _mousePointer;
-        private WriteableBitmap _lastRenderedFrame;
-        private object _sync = new object();
+        private readonly IKeyboardDevice _keyboard;
+        private readonly Stopwatch _st = Stopwatch.StartNew();
+        private readonly Pointer _mousePointer;
+        private WriteableBitmap? _lastRenderedFrame;
+        private readonly object _sync = new object();
         public bool IsPopup { get; }
 
         public HeadlessWindowImpl(bool isPopup)
         {
             IsPopup = isPopup;
             Surfaces = new object[] { this };
-            _keyboard = AvaloniaLocator.Current.GetService<IKeyboardDevice>();
+            _keyboard = AvaloniaLocator.Current.GetRequiredService<IKeyboardDevice>();
             _mousePointer = new Pointer(Pointer.GetNextFreeId(), PointerType.Mouse, true);
             MouseDevice = new MouseDevice(_mousePointer);
             ClientSize = new Size(1024, 768);
@@ -48,13 +48,13 @@ namespace Avalonia.Headless
         public double RenderScaling { get; } = 1;
         public double DesktopScaling => RenderScaling;
         public IEnumerable<object> Surfaces { get; }
-        public Action<RawInputEventArgs> Input { get; set; }
-        public Action<Rect> Paint { get; set; }
-        public Action<Size, WindowResizeReason> Resized { get; set; }
-        public Action<double> ScalingChanged { get; set; }
+        public Action<RawInputEventArgs>? Input { get; set; }
+        public Action<Rect>? Paint { get; set; }
+        public Action<Size, WindowResizeReason>? Resized { get; set; }
+        public Action<double>? ScalingChanged { get; set; }
 
         public IRenderer CreateRenderer(IRenderRoot root) =>
-            new CompositingRenderer(root, AvaloniaHeadlessPlatform.Compositor, () => Surfaces);
+            new CompositingRenderer(root, AvaloniaHeadlessPlatform.Compositor!, () => Surfaces);
 
         public void Invalidate(Rect rect)
         {
@@ -65,18 +65,18 @@ namespace Avalonia.Headless
             InputRoot = inputRoot;
         }
 
-        public IInputRoot InputRoot { get; set; }
+        public IInputRoot? InputRoot { get; set; }
 
         public Point PointToClient(PixelPoint point) => point.ToPoint(RenderScaling);
 
         public PixelPoint PointToScreen(Point point) => PixelPoint.FromPoint(point, RenderScaling);
 
-        public void SetCursor(ICursorImpl cursor)
+        public void SetCursor(ICursorImpl? cursor)
         {
 
         }
 
-        public Action Closed { get; set; }
+        public Action? Closed { get; set; }
         public IMouseDevice MouseDevice { get; }
 
         public void Show(bool activate, bool isDialog)
@@ -101,14 +101,14 @@ namespace Avalonia.Headless
         }
 
         public PixelPoint Position { get; set; }
-        public Action<PixelPoint> PositionChanged { get; set; }
+        public Action<PixelPoint>? PositionChanged { get; set; }
         public void Activate()
         {
             Dispatcher.UIThread.Post(() => Activated?.Invoke(), DispatcherPriority.Input);
         }
 
-        public Action Deactivated { get; set; }
-        public Action Activated { get; set; }
+        public Action? Deactivated { get; set; }
+        public Action? Activated { get; set; }
         public IPlatformHandle Handle { get; } = new PlatformHandle(IntPtr.Zero, "STUB");
         public Size MaxClientSize { get; } = new Size(1920, 1280);
         public void Resize(Size clientSize, WindowResizeReason reason)
@@ -123,7 +123,7 @@ namespace Avalonia.Headless
                 });
         }
 
-        void DoResize(Size clientSize)
+        private void DoResize(Size clientSize)
         {
             // Uncomment this check and experience a weird bug in layout engine
             if (ClientSize != clientSize)
@@ -145,8 +145,8 @@ namespace Avalonia.Headless
 
         public IScreenImpl Screen { get; } = new HeadlessScreensStub();
         public WindowState WindowState { get; set; }
-        public Action<WindowState> WindowStateChanged { get; set; }
-        public void SetTitle(string title)
+        public Action<WindowState>? WindowStateChanged { get; set; }
+        public void SetTitle(string? title)
         {
 
         }
@@ -156,7 +156,7 @@ namespace Avalonia.Headless
 
         }
 
-        public void SetIcon(IWindowIconImpl icon)
+        public void SetIcon(IWindowIconImpl? icon)
         {
 
         }
@@ -171,9 +171,9 @@ namespace Avalonia.Headless
 
         }
 
-        public Func<WindowCloseReason, bool> Closing { get; set; }
+        public Func<WindowCloseReason, bool>? Closing { get; set; }
 
-        class FramebufferProxy : ILockedFramebuffer
+        private class FramebufferProxy : ILockedFramebuffer
         {
             private readonly ILockedFramebuffer _fb;
             private readonly Action _onDispose;
@@ -214,28 +214,37 @@ namespace Avalonia.Headless
             });
         }
 
-        public IRef<IWriteableBitmapImpl> GetLastRenderedFrame()
+        public Bitmap? GetLastRenderedFrame()
         {
             lock (_sync)
-                return _lastRenderedFrame?.PlatformImpl?.CloneAs<IWriteableBitmapImpl>();
+            {
+                if (_lastRenderedFrame is null)
+                {
+                    return null;
+                }
+
+                using var lockedFramebuffer = _lastRenderedFrame.Lock();
+                return new Bitmap(lockedFramebuffer.Format, AlphaFormat.Opaque, lockedFramebuffer.Address,
+                    lockedFramebuffer.Size, lockedFramebuffer.Dpi, lockedFramebuffer.RowBytes);
+            }
         }
 
         private ulong Timestamp => (ulong)_st.ElapsedMilliseconds;
 
         // TODO: Hook recent Popup changes. 
-        IPopupPositioner IPopupImpl.PopupPositioner => null;
+        IPopupPositioner IPopupImpl.PopupPositioner => null!;
 
         public Size MaxAutoSizeHint => new Size(1920, 1080);
 
-        public Action<WindowTransparencyLevel> TransparencyLevelChanged { get; set; }
+        public Action<WindowTransparencyLevel>? TransparencyLevelChanged { get; set; }
 
         public WindowTransparencyLevel TransparencyLevel => WindowTransparencyLevel.None;
 
-        public Action GotInputWhenDisabled { get; set; }
+        public Action? GotInputWhenDisabled { get; set; }
 
         public bool IsClientAreaExtendedToDecorations => false;
 
-        public Action<bool> ExtendClientAreaToDecorationsChanged { get; set; }
+        public Action<bool>? ExtendClientAreaToDecorationsChanged { get; set; }
 
         public bool NeedsManagedDecorations => false;
 
@@ -243,17 +252,12 @@ namespace Avalonia.Headless
 
         public Thickness OffScreenMargin => new Thickness();
 
-        public Action LostFocus { get; set; }
+        public Action? LostFocus { get; set; }
 
         public AcrylicPlatformCompensationLevels AcrylicCompensationLevels => new AcrylicPlatformCompensationLevels(1, 1, 1);
-        public object TryGetFeature(Type featureType)
+        public object? TryGetFeature(Type featureType)
         {
-            if (featureType == typeof(IStorageProvider))
-            {
-                return new NoopStorageProvider();
-            }
-
-            if(featureType == typeof(IClipboard))
+        	if(featureType == typeof(IClipboard))
             {
                 return AvaloniaLocator.Current.GetRequiredService<IClipboard>();
             }
@@ -263,46 +267,58 @@ namespace Avalonia.Headless
 
         void IHeadlessWindow.KeyPress(Key key, RawInputModifiers modifiers)
         {
-            Input?.Invoke(new RawKeyEventArgs(_keyboard, Timestamp, InputRoot, RawKeyEventType.KeyDown, key, modifiers));
+            Input?.Invoke(new RawKeyEventArgs(_keyboard, Timestamp, InputRoot!, RawKeyEventType.KeyDown, key, modifiers));
         }
 
         void IHeadlessWindow.KeyRelease(Key key, RawInputModifiers modifiers)
         {
-            Input?.Invoke(new RawKeyEventArgs(_keyboard, Timestamp, InputRoot, RawKeyEventType.KeyUp, key, modifiers));
+            Input?.Invoke(new RawKeyEventArgs(_keyboard, Timestamp, InputRoot!, RawKeyEventType.KeyUp, key, modifiers));
         }
 
-        void IHeadlessWindow.MouseDown(Point point, int button, RawInputModifiers modifiers)
+        void IHeadlessWindow.MouseDown(Point point, MouseButton button, RawInputModifiers modifiers)
         {
-            Input?.Invoke(new RawPointerEventArgs(MouseDevice, Timestamp, InputRoot,
-                button == 0 ? RawPointerEventType.LeftButtonDown :
-                button == 1 ? RawPointerEventType.MiddleButtonDown : RawPointerEventType.RightButtonDown,
-                point, modifiers));
+            Input?.Invoke(new RawPointerEventArgs(MouseDevice, Timestamp, InputRoot!,
+                button switch
+                {
+                    MouseButton.Left => RawPointerEventType.LeftButtonDown,
+                    MouseButton.Right => RawPointerEventType.RightButtonDown,
+                    MouseButton.Middle => RawPointerEventType.MiddleButtonDown,
+                    MouseButton.XButton1 => RawPointerEventType.XButton1Down,
+                    MouseButton.XButton2 => RawPointerEventType.XButton2Down,
+                    _ => RawPointerEventType.Move,
+                }, point, modifiers));
         }
 
         void IHeadlessWindow.MouseMove(Point point, RawInputModifiers modifiers)
         {
-            Input?.Invoke(new RawPointerEventArgs(MouseDevice, Timestamp, InputRoot,
+            Input?.Invoke(new RawPointerEventArgs(MouseDevice, Timestamp, InputRoot!,
                 RawPointerEventType.Move, point, modifiers));
         }
 
-        void IHeadlessWindow.MouseUp(Point point, int button, RawInputModifiers modifiers)
+        void IHeadlessWindow.MouseUp(Point point, MouseButton button, RawInputModifiers modifiers)
         {
-            Input?.Invoke(new RawPointerEventArgs(MouseDevice, Timestamp, InputRoot,
-                button == 0 ? RawPointerEventType.LeftButtonUp :
-                button == 1 ? RawPointerEventType.MiddleButtonUp : RawPointerEventType.RightButtonUp,
-                point, modifiers));
+            Input?.Invoke(new RawPointerEventArgs(MouseDevice, Timestamp, InputRoot!,
+                button switch
+                {
+                    MouseButton.Left => RawPointerEventType.LeftButtonUp,
+                    MouseButton.Right => RawPointerEventType.RightButtonUp,
+                    MouseButton.Middle => RawPointerEventType.MiddleButtonUp,
+                    MouseButton.XButton1 => RawPointerEventType.XButton1Up,
+                    MouseButton.XButton2 => RawPointerEventType.XButton2Up,
+                    _ => RawPointerEventType.Move,
+                }, point, modifiers));
         }
         
         void IHeadlessWindow.MouseWheel(Point point, Vector delta, RawInputModifiers modifiers)
         {
-            Input?.Invoke(new RawMouseWheelEventArgs(MouseDevice, Timestamp, InputRoot,
+            Input?.Invoke(new RawMouseWheelEventArgs(MouseDevice, Timestamp, InputRoot!,
                 point, delta, modifiers));
         }
         
         void IHeadlessWindow.DragDrop(Point point, RawDragEventType type, IDataObject data, DragDropEffects effects, RawInputModifiers modifiers)
         {
             var device = AvaloniaLocator.Current.GetRequiredService<IDragDropDevice>();
-            Input?.Invoke(new RawDragEvent(device, type, InputRoot, point, data, effects, modifiers));
+            Input?.Invoke(new RawDragEvent(device, type, InputRoot!, point, data, effects, modifiers));
         }
 
         void IWindowImpl.Move(PixelPoint point)
@@ -310,7 +326,7 @@ namespace Avalonia.Headless
 
         }
 
-        public IPopupImpl CreatePopup()
+        public IPopupImpl? CreatePopup()
         {
             // TODO: Hook recent Popup changes. 
             return null;

+ 5 - 5
src/Avalonia.Headless/IHeadlessWindow.cs → src/Headless/Avalonia.Headless/IHeadlessWindow.cs

@@ -6,15 +6,15 @@ using Avalonia.Utilities;
 
 namespace Avalonia.Headless
 {
-    public interface IHeadlessWindow
+    internal interface IHeadlessWindow
     {
-        IRef<IWriteableBitmapImpl> GetLastRenderedFrame();
+        Bitmap? GetLastRenderedFrame();
         void KeyPress(Key key, RawInputModifiers modifiers);
         void KeyRelease(Key key, RawInputModifiers modifiers);
-        void MouseDown(Point point, int button, RawInputModifiers modifiers = RawInputModifiers.None);
+        void MouseDown(Point point, MouseButton button, RawInputModifiers modifiers = RawInputModifiers.None);
         void MouseMove(Point point, RawInputModifiers modifiers = RawInputModifiers.None);
-        void MouseUp(Point point, int button, RawInputModifiers modifiers = RawInputModifiers.None);
+        void MouseUp(Point point, MouseButton button, RawInputModifiers modifiers = RawInputModifiers.None);
         void MouseWheel(Point point, Vector delta, RawInputModifiers modifiers = RawInputModifiers.None);
-        void DragDrop(Point point, RawDragEventType type, IDataObject data, DragDropEffects effects, RawInputModifiers modifiers);
+        void DragDrop(Point point, RawDragEventType type, IDataObject data, DragDropEffects effects, RawInputModifiers modifiers = RawInputModifiers.None);
     }
 }

+ 19 - 0
tests/Avalonia.Headless.UnitTests/Avalonia.Headless.UnitTests.csproj

@@ -0,0 +1,19 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+  <PropertyGroup>
+    <TargetFramework>net6.0</TargetFramework>
+    <IsTestProject>true</IsTestProject>
+  </PropertyGroup>
+  <Import Project="..\..\build\UnitTests.NetCore.targets" />
+  <Import Project="..\..\build\UnitTests.NetFX.props" />
+  <Import Project="..\..\build\Moq.props" />
+  <Import Project="..\..\build\XUnit.props" />
+  <Import Project="..\..\build\Rx.props" />
+  <Import Project="..\..\build\SharedVersion.props" />
+
+  <ItemGroup>
+    <ProjectReference Include="..\..\src\Avalonia.Themes.Simple\Avalonia.Themes.Simple.csproj" />
+    <ProjectReference Include="..\..\src\Headless\Avalonia.Headless.XUnit\Avalonia.Headless.XUnit.csproj" />
+    <ProjectReference Include="..\..\src\Skia\Avalonia.Skia\Avalonia.Skia.csproj" />
+  </ItemGroup>
+</Project>

+ 36 - 0
tests/Avalonia.Headless.UnitTests/InputTests.cs

@@ -0,0 +1,36 @@
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Layout;
+using Avalonia.Threading;
+using Xunit;
+
+namespace Avalonia.Headless.UnitTests;
+
+public class InputTests
+{
+    [Fact]
+    public void Should_Click_Button_On_Window()
+    {
+        var buttonClicked = false;
+        var button = new Button
+        {
+            HorizontalAlignment = HorizontalAlignment.Stretch,
+            VerticalAlignment = VerticalAlignment.Stretch
+        };
+
+        button.Click += (_, _) => buttonClicked = true;
+
+        var window = new Window
+        {
+            Width = 100,
+            Height = 100,
+            Content = button
+        };
+        window.Show();
+
+        window.MouseDown(new Point(50, 50), MouseButton.Left);
+        window.MouseUp(new Point(50, 50), MouseButton.Left);
+        
+        Assert.True(buttonClicked);
+    }
+}

+ 33 - 0
tests/Avalonia.Headless.UnitTests/RenderingTests.cs

@@ -0,0 +1,33 @@
+using Avalonia.Controls;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Threading;
+using Xunit;
+
+namespace Avalonia.Headless.UnitTests;
+
+public class RenderingTests
+{
+    [Fact]
+    public void Should_Render_Last_Frame_To_Bitmap()
+    {
+        var window = new Window
+        {
+            Content = new ContentControl
+            {
+                HorizontalAlignment = HorizontalAlignment.Stretch,
+                VerticalAlignment = VerticalAlignment.Stretch,
+                Padding = new Thickness(4),
+                Content = new PathIcon
+                {
+                    Data = StreamGeometry.Parse("M0,9 L10,0 20,9 19,10 10,2 1,10 z")
+                }
+            },
+            SizeToContent = SizeToContent.WidthAndHeight
+        };
+        window.Show();
+
+        var frame = window.CaptureRenderedFrame();
+        Assert.NotNull(frame);
+    }
+}

+ 24 - 0
tests/Avalonia.Headless.UnitTests/TestApplication.cs

@@ -0,0 +1,24 @@
+using Avalonia.Headless.UnitTests;
+using Avalonia.Headless.XUnit;
+using Avalonia.Themes.Simple;
+using Xunit;
+
+[assembly: AvaloniaTestFramework(typeof(TestApplication))]
+[assembly: CollectionBehavior(DisableTestParallelization = true)]
+
+namespace Avalonia.Headless.UnitTests;
+
+public class TestApplication : Application
+{
+    public TestApplication()
+    {
+        Styles.Add(new SimpleTheme());
+    }
+
+    public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure<TestApplication>()
+        .UseSkia()
+        .UseHeadless(new AvaloniaHeadlessPlatformOptions
+        {
+            UseHeadlessDrawing = false
+        });
+}

+ 32 - 0
tests/Avalonia.Headless.UnitTests/ThreadingTests.cs

@@ -0,0 +1,32 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Avalonia.Threading;
+using Xunit;
+
+namespace Avalonia.Headless.UnitTests;
+
+public class ThreadingTests
+{
+    [Fact]
+    public void Should_Be_On_Dispatcher_Thread()
+    {
+        Dispatcher.UIThread.VerifyAccess();
+    }
+    
+    [Fact]
+    public async Task DispatcherTimer_Works_On_The_Same_Thread()
+    {
+        var currentThread = Thread.CurrentThread;
+        var tcs = new TaskCompletionSource();
+
+        DispatcherTimer.RunOnce(() =>
+        {
+            Assert.Equal(currentThread, Thread.CurrentThread);
+
+            tcs.SetResult();
+        }, TimeSpan.FromTicks(1));
+
+        await tcs.Task;
+    }
+}