diff --git a/Avalonia.sln b/Avalonia.sln
index d4ccdfdc69..b4148f9337 100644
--- a/Avalonia.sln
+++ b/Avalonia.sln
@@ -264,7 +264,11 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Headless", "Headless", "{FF
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}"
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Avalonia.Headless.NUnit", "src\Headless\Avalonia.Headless.NUnit\Avalonia.Headless.NUnit.csproj", "{ED976634-B118-43F8-8B26-0279C7A7044F}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Avalonia.Headless.XUnit.UnitTests", "tests\Avalonia.Headless.XUnit.UnitTests\Avalonia.Headless.XUnit.UnitTests.csproj", "{EBA7613E-C36C-4E0C-AB45-71B143F86219}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Avalonia.Headless.NUnit.UnitTests", "tests\Avalonia.Headless.NUnit.UnitTests\Avalonia.Headless.NUnit.UnitTests.csproj", "{47025FBC-2130-42EE-98C9-D3989B3B9446}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@@ -609,10 +613,6 @@ Global
{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
@@ -637,6 +637,18 @@ Global
{FC956F9A-4C3A-4A1A-ACDD-BB54DCB661DD}.Release|Any CPU.ActiveCfg = Release|Any CPU
{FC956F9A-4C3A-4A1A-ACDD-BB54DCB661DD}.Release|Any CPU.Build.0 = Release|Any CPU
{FC956F9A-4C3A-4A1A-ACDD-BB54DCB661DD}.Release|Any CPU.Deploy.0 = Release|Any CPU
+ {ED976634-B118-43F8-8B26-0279C7A7044F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {ED976634-B118-43F8-8B26-0279C7A7044F}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {ED976634-B118-43F8-8B26-0279C7A7044F}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {ED976634-B118-43F8-8B26-0279C7A7044F}.Release|Any CPU.Build.0 = Release|Any CPU
+ {EBA7613E-C36C-4E0C-AB45-71B143F86219}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {EBA7613E-C36C-4E0C-AB45-71B143F86219}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {EBA7613E-C36C-4E0C-AB45-71B143F86219}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {EBA7613E-C36C-4E0C-AB45-71B143F86219}.Release|Any CPU.Build.0 = Release|Any CPU
+ {47025FBC-2130-42EE-98C9-D3989B3B9446}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {47025FBC-2130-42EE-98C9-D3989B3B9446}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {47025FBC-2130-42EE-98C9-D3989B3B9446}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {47025FBC-2130-42EE-98C9-D3989B3B9446}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -707,7 +719,6 @@ Global
{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}
@@ -715,6 +726,9 @@ Global
{22E3BC08-EAF7-4889-BDC4-B4D3046C4E2D} = {9B9E3891-2366-4253-A952-D08BCEB71098}
{4CDAD037-34A2-4CCF-A03A-C6C7B988A572} = {9B9E3891-2366-4253-A952-D08BCEB71098}
{FC956F9A-4C3A-4A1A-ACDD-BB54DCB661DD} = {9B9E3891-2366-4253-A952-D08BCEB71098}
+ {ED976634-B118-43F8-8B26-0279C7A7044F} = {FF237916-7150-496B-89ED-6CA3292896E7}
+ {EBA7613E-C36C-4E0C-AB45-71B143F86219} = {C5A00AC3-B34C-4564-9BDD-2DA473EF4D8B}
+ {47025FBC-2130-42EE-98C9-D3989B3B9446} = {C5A00AC3-B34C-4564-9BDD-2DA473EF4D8B}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {87366D66-1391-4D90-8999-95A620AD786A}
diff --git a/src/Headless/Avalonia.Headless.NUnit/Avalonia.Headless.NUnit.csproj b/src/Headless/Avalonia.Headless.NUnit/Avalonia.Headless.NUnit.csproj
new file mode 100644
index 0000000000..3b8950d5d1
--- /dev/null
+++ b/src/Headless/Avalonia.Headless.NUnit/Avalonia.Headless.NUnit.csproj
@@ -0,0 +1,18 @@
+
+
+ netstandard2.0;net6.0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Headless/Avalonia.Headless.NUnit/AvaloniaTest.cs b/src/Headless/Avalonia.Headless.NUnit/AvaloniaTest.cs
new file mode 100644
index 0000000000..fd04146391
--- /dev/null
+++ b/src/Headless/Avalonia.Headless.NUnit/AvaloniaTest.cs
@@ -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;
+
+///
+/// Identifies a nunit test that starts on Avalonia Dispatcher
+/// such that awaited expressions resume on the test's "main thread".
+///
+[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 new AvaloniaTestCommand(session, command);
+ }
+}
diff --git a/src/Headless/Avalonia.Headless.NUnit/AvaloniaTestCommand.cs b/src/Headless/Avalonia.Headless.NUnit/AvaloniaTestCommand.cs
new file mode 100644
index 0000000000..e5eabb612a
--- /dev/null
+++ b/src/Headless/Avalonia.Headless.NUnit/AvaloniaTestCommand.cs
@@ -0,0 +1,44 @@
+using System.Threading.Tasks;
+using NUnit.Framework.Interfaces;
+using NUnit.Framework.Internal;
+using NUnit.Framework.Internal.Commands;
+
+namespace Avalonia.Headless.NUnit;
+
+internal class AvaloniaTestCommand : DelegatingTestCommand
+{
+ private readonly HeadlessUnitTestSession _session;
+
+ public AvaloniaTestCommand(HeadlessUnitTestSession session, TestCommand innerCommand)
+ : base(innerCommand)
+ {
+ _session = session;
+ }
+
+ public override TestResult Execute(TestExecutionContext context)
+ {
+ return _session.Dispatcher.InvokeAsync>(async () =>
+ {
+ 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();
+
+ return context.CurrentResult;
+ }).GetTask().Unwrap().Result;
+ }
+}
diff --git a/src/Headless/Avalonia.Headless.NUnit/AvaloniaTheory.cs b/src/Headless/Avalonia.Headless.NUnit/AvaloniaTheory.cs
new file mode 100644
index 0000000000..1f3cadd296
--- /dev/null
+++ b/src/Headless/Avalonia.Headless.NUnit/AvaloniaTheory.cs
@@ -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;
+
+///
+/// Identifies a nunit theory that starts on Avalonia Dispatcher
+/// such that awaited expressions resume on the test's "main thread".
+///
+[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 new AvaloniaTestCommand(session, command);
+ }
+}
diff --git a/src/Headless/Avalonia.Headless.NUnit/ExecutionQueue.cs b/src/Headless/Avalonia.Headless.NUnit/ExecutionQueue.cs
new file mode 100644
index 0000000000..5488fc956a
--- /dev/null
+++ b/src/Headless/Avalonia.Headless.NUnit/ExecutionQueue.cs
@@ -0,0 +1,35 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Avalonia.Threading;
+
+namespace Avalonia.Headless.NUnit;
+
+internal static class ExecutionQueue
+{
+ static bool _running;
+ static Queue> _queue = new();
+ static async void TryExecuteNext()
+ {
+ if (_running || _queue.Count == 0) return;
+ try
+ {
+ _running = true;
+ await _queue.Dequeue()();
+ }
+ finally
+ {
+ _running = false;
+ }
+ TryExecuteNext();
+ }
+
+ static void ExecuteOnQueue(this Dispatcher dispatcher, Func cb)
+ {
+ dispatcher.Post(() =>
+ {
+ _queue.Enqueue(cb);
+ TryExecuteNext();
+ });
+ }
+}