diff --git a/Avalonia.sln b/Avalonia.sln index 295f7cb149..6d0c4dee2a 100644 --- a/Avalonia.sln +++ b/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} diff --git a/src/Avalonia.Controls/AppBuilder.cs b/src/Avalonia.Controls/AppBuilder.cs index cf79fcd1a8..871d6bf52a 100644 --- a/src/Avalonia.Controls/AppBuilder.cs +++ b/src/Avalonia.Controls/AppBuilder.cs @@ -116,6 +116,43 @@ namespace Avalonia }; } + /// + /// Begin configuring an . + /// Should only be used for testing and design purposes, as it relies on dynamic code. + /// + /// + /// Parameter from which should be created. + /// It either needs to have BuildAvaloniaApp -> AppBuilder method or inherit Application. + /// + /// An instance. If can't be created, thrown an exception. + 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(), + null)? + .Invoke(null, Array.Empty()); + + 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 callback) @@ -204,7 +241,7 @@ namespace Avalonia _optionsInitializers += () => { AvaloniaLocator.CurrentMutable.Bind().ToFunc(options); }; return Self; } - + /// /// Sets up the platform-specific services for the . /// diff --git a/src/Avalonia.Controls/Avalonia.Controls.csproj b/src/Avalonia.Controls/Avalonia.Controls.csproj index 5adb5a8f2e..b9e2d3d259 100644 --- a/src/Avalonia.Controls/Avalonia.Controls.csproj +++ b/src/Avalonia.Controls/Avalonia.Controls.csproj @@ -17,5 +17,6 @@ + diff --git a/src/Avalonia.DesignerSupport/Remote/RemoteDesignerEntryPoint.cs b/src/Avalonia.DesignerSupport/Remote/RemoteDesignerEntryPoint.cs index 85605ccd9d..313063269b 100644 --- a/src/Avalonia.DesignerSupport/Remote/RemoteDesignerEntryPoint.cs +++ b/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(), - 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); diff --git a/src/Headless/Avalonia.Headless.XUnit/Avalonia.Headless.XUnit.csproj b/src/Headless/Avalonia.Headless.XUnit/Avalonia.Headless.XUnit.csproj new file mode 100644 index 0000000000..c2c58b4f94 --- /dev/null +++ b/src/Headless/Avalonia.Headless.XUnit/Avalonia.Headless.XUnit.csproj @@ -0,0 +1,19 @@ + + + net6.0 + enable + enable + + + + + + + + + + + + + + diff --git a/src/Headless/Avalonia.Headless.XUnit/AvaloniaTestFramework.cs b/src/Headless/Avalonia.Headless.XUnit/AvaloniaTestFramework.cs new file mode 100644 index 0000000000..21086fa946 --- /dev/null +++ b/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 : 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 testCases, + IMessageSink executionMessageSink, + ITestFrameworkExecutionOptions executionOptions) + { + executionOptions.SetValue("xunit.execution.DisableParallelization", false); + using (var assemblyRunner = new AvaloniaTestRunner( + TestAssembly, testCases, DiagnosticMessageSink, executionMessageSink, + executionOptions)) await assemblyRunner.RunAsync(); + } + } +} diff --git a/src/Headless/Avalonia.Headless.XUnit/AvaloniaTestFrameworkAttribute.cs b/src/Headless/Avalonia.Headless.XUnit/AvaloniaTestFrameworkAttribute.cs new file mode 100644 index 0000000000..d0249a5b25 --- /dev/null +++ b/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; + +/// +/// +/// +[TestFrameworkDiscoverer("Avalonia.Headless.XUnit.AvaloniaTestFrameworkTypeDiscoverer", "Avalonia.Headless.XUnit")] +[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = false)] +public sealed class AvaloniaTestFrameworkAttribute : Attribute, ITestFrameworkAttribute +{ + /// + /// Creates instance of . + /// + /// + /// Parameter from which should be created. + /// It either needs to have BuildAvaloniaApp -> AppBuilder method or inherit Application. + /// + public AvaloniaTestFrameworkAttribute( + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods | DynamicallyAccessedMemberTypes.NonPublicMethods | DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] + Type appBuilderEntryPointType) { } +} + +/// +/// Discoverer implementation for the Avalonia testing framework. +/// +public class AvaloniaTestFrameworkTypeDiscoverer : ITestFrameworkTypeDiscoverer +{ + /// + /// Creates instance of . + /// + public AvaloniaTestFrameworkTypeDiscoverer(IMessageSink _) + { + } + + /// + 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); + } +} diff --git a/src/Headless/Avalonia.Headless.XUnit/AvaloniaTestRunner.cs b/src/Headless/Avalonia.Headless.XUnit/AvaloniaTestRunner.cs new file mode 100644 index 0000000000..579232521d --- /dev/null +++ b/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 : XunitTestAssemblyRunner +{ + private CancellationTokenSource? _cancellationTokenSource; + + public AvaloniaTestRunner(ITestAssembly testAssembly, IEnumerable 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 InitNewApplicationContext(CancellationToken cancellationToken) + { + var tcs = new TaskCompletionSource(); + + 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; + } +}