Browse Source

Add headless xunit integration project

Max Katz 2 years ago
parent
commit
a26566548a

+ 7 - 0
Avalonia.sln

@@ -248,6 +248,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Avalonia.Fonts.Inter", "src
 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
 Global
 	GlobalSection(SolutionConfigurationPlatforms) = preSolution
 		Debug|Any CPU = Debug|Any CPU
@@ -579,6 +581,10 @@ 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
 	EndGlobalSection
 	GlobalSection(SolutionProperties) = preSolution
 		HideSolutionNode = FALSE
@@ -648,6 +654,7 @@ Global
 		{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}
 	EndGlobalSection
 	GlobalSection(ExtensibilityGlobals) = postSolution
 		SolutionGuid = {87366D66-1391-4D90-8999-95A620AD786A}

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

@@ -116,6 +116,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)
@@ -204,7 +241,7 @@ namespace Avalonia
             _optionsInitializers += () => { AvaloniaLocator.CurrentMutable.Bind<T>().ToFunc(options); };
             return Self;
         }
-        
+
         /// <summary>
         /// Sets up the platform-specific services for the <see cref="Application"/>.
         /// </summary>

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

@@ -17,5 +17,6 @@
     <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)" />
   </ItemGroup>
 </Project>

+ 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);

+ 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>
+/// 
+/// </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?.Dispose();
+        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;
+    }
+}