From 6896f08f3783cc5bf4e14b5a92be65c3555aa523 Mon Sep 17 00:00:00 2001 From: Nikita Tsukanov Date: Mon, 31 Aug 2026 11:16:36 +0000 Subject: [PATCH] Basic infra to use headless platform in "normal" unit tests (#22078) * Basic infra for using headless platform in normal tests * Add RunJobs helper to scope returned from HeadlessUnitTestApplication --- .../HeadlessProbeTests.cs | 150 ++++++++++++++++++ .../HeadlessUnitTestApplication.cs | 83 ++++++++++ 2 files changed, 233 insertions(+) create mode 100644 tests/Avalonia.Controls.UnitTests/HeadlessProbeTests.cs create mode 100644 tests/Avalonia.UnitTests/HeadlessUnitTestApplication.cs diff --git a/tests/Avalonia.Controls.UnitTests/HeadlessProbeTests.cs b/tests/Avalonia.Controls.UnitTests/HeadlessProbeTests.cs new file mode 100644 index 0000000000..ad05ae45dc --- /dev/null +++ b/tests/Avalonia.Controls.UnitTests/HeadlessProbeTests.cs @@ -0,0 +1,150 @@ +using System; +using System.Linq; +using Avalonia.Controls.Primitives; +using Avalonia.Headless; +using Avalonia.Input; +using Avalonia.Input.Platform; +using Avalonia.Platform; +using Avalonia.Rendering; +using Avalonia.Threading; +using Avalonia.UnitTests; +using Avalonia.VisualTree; +using Xunit; + +namespace Avalonia.Controls.UnitTests; + +public class HeadlessProbeTests : ScopedTestBase +{ + [Fact] + public void Can_Show_Window_And_Lay_Out() + { + using var app = HeadlessUnitTestApplication.Start(); + + var button = new Button { Width = 100, Height = 30, Content = "hi" }; + var window = new Window { Width = 400, Height = 300, Content = button }; + window.Show(); + app.RunJobs(); + + Assert.Equal(new Size(100, 30), button.Bounds.Size); + Assert.Equal(new Size(400, 300), window.ClientSize); + } + + [Fact] + public void Can_Route_Real_Input_Through_Headless_Toplevel() + { + using var app = HeadlessUnitTestApplication.Start(); + + var clicked = false; + var button = new Button { Width = 100, Height = 30, Content = "hi" }; + button.Click += (_, _) => clicked = true; + var window = new Window { Width = 400, Height = 300, Content = button }; + window.Show(); + app.RunJobs(); + + var pt = button.Bounds.Center; + window.MouseDown(pt, MouseButton.Left); + window.MouseUp(pt, MouseButton.Left); + app.RunJobs(); + + Assert.True(clicked); + } + + [Fact] + public void Keyboard_Input_Reaches_Focused_Control() + { + using var app = HeadlessUnitTestApplication.Start(); + + var box = new TextBox { Width = 200, Height = 30 }; + var window = new Window { Width = 400, Height = 300, Content = box }; + window.Show(); + app.RunJobs(); + + box.Focus(); + app.RunJobs(); + window.KeyTextInput("abc"); + app.RunJobs(); + + Assert.Equal("abc", box.Text); + } + + [Fact] + public void Popup_Opens_As_Real_PopupRoot() + { + using var app = HeadlessUnitTestApplication.Start(); + + var popup = new Popup { Placement = PlacementMode.Pointer, Child = new Border { Width = 50, Height = 50 } }; + var window = new Window { Width = 400, Height = 300, Content = new Panel { Children = { popup } } }; + window.Show(); + app.RunJobs(); + + popup.Open(); + app.RunJobs(); + + Assert.IsType(popup.Host); + } + + [Fact] + public void Popup_Uses_OverlayPopupHost_When_OverlayPopups_Enabled() + { + using var app = HeadlessUnitTestApplication.Start(new AvaloniaHeadlessPlatformOptions { OverlayPopups = true }); + + var popup = new Popup { Placement = PlacementMode.Pointer, Child = new Border { Width = 50, Height = 50 } }; + var window = new Window { Width = 400, Height = 300, Content = new Panel { Children = { popup } } }; + window.Show(); + app.RunJobs(); + + popup.Open(); + app.RunJobs(); + + Assert.IsType(popup.Host); + } + + [Fact] + public void Overlay_Popup_Requires_An_Applied_Window_Template() + { + using var app = HeadlessUnitTestApplication.Start(new AvaloniaHeadlessPlatformOptions { OverlayPopups = true }); + + // The overlay layer is looked up through the visual tree, so a window that was + // never shown has nothing to find. + var untemplated = new Popup { PlacementTarget = new Window() }; + var ex = Assert.Throws(() => untemplated.Open()); + Assert.Contains("no overlay layer is found", ex.Message); + + var window = new Window(); + window.Show(); + app.RunJobs(); + Assert.NotNull(window.GetVisualDescendants().OfType().FirstOrDefault()); + + var templated = new Popup { PlacementTarget = window }; + templated.Open(); + Assert.IsType(templated.Host); + } + + [Fact] + public void Platform_Services_Are_Registered_Before_The_First_Window_And_Stay_Stable() + { + using var app = HeadlessUnitTestApplication.Start(); + + object?[] Resolve() => + [ + AvaloniaLocator.Current.GetService(), + AvaloniaLocator.Current.GetService(), + AvaloniaLocator.Current.GetService(), + AvaloniaLocator.Current.GetService(), + AvaloniaLocator.Current.GetService(), + AvaloniaLocator.Current.GetService(), + AvaloniaLocator.Current.GetService(), + AvaloniaLocator.Current.GetService(), + ]; + + var before = Resolve(); + Assert.All(before, Assert.NotNull); + + new Window().Show(); + app.RunJobs(); + + // Initializing the platform lazily used to replace these mid-test, leaving anything that + // resolved early holding a different instance than the window does. + Assert.Equal(before, Resolve(), System.Collections.Generic.ReferenceEqualityComparer.Instance); + } +} diff --git a/tests/Avalonia.UnitTests/HeadlessUnitTestApplication.cs b/tests/Avalonia.UnitTests/HeadlessUnitTestApplication.cs new file mode 100644 index 0000000000..f73ba77f94 --- /dev/null +++ b/tests/Avalonia.UnitTests/HeadlessUnitTestApplication.cs @@ -0,0 +1,83 @@ +using System; +using System.Threading; +using Avalonia.Controls; +using Avalonia.Headless; +using Avalonia.Input; +using Avalonia.Media; +using Avalonia.Platform; +using Avalonia.Themes.Simple; +using Avalonia.Threading; +using Xunit; + +namespace Avalonia.UnitTests; + +/// +/// Test application running on the real headless platform, set up through +/// so that the platform owns its own slice of the locator. +/// +/// +/// This is deliberately not a flavour: the headless platform registers +/// services of its own (keyboard device, clipboard, render loop, platform settings, hotkey config), +/// and binds the same keys from its service bag, so the two +/// overwrite each other depending on when the platform happens to initialize. +/// +public class HeadlessUnitTestApplication : Application +{ + public HeadlessUnitTestApplication() + { + Styles.Add(new SimpleTheme()); + } + + public static Scope Start(AvaloniaHeadlessPlatformOptions? options = null) + { + var scope = AvaloniaLocator.EnterScope(); + var oldContext = SynchronizationContext.Current; + + try + { + Dispatcher.ResetBeforeUnitTests(); + + AppBuilder.Configure() + // Popups default to dedicated top-levels here, matching the desktop platforms + // and the app used by Avalonia.Headless.UnitTests. + .UseHeadless(options ?? new AvaloniaHeadlessPlatformOptions { OverlayPopups = false }) + .AfterPlatformServicesSetup(_ => AvaloniaLocator.CurrentMutable + .Bind().ToConstant(new TestFontManager())) + .SetupUnsafe(); + } + catch + { + scope.Dispose(); + throw; + } + + return new Scope(scope, oldContext); + } + + public sealed class Scope(IDisposable locatorScope, SynchronizationContext? oldContext) : IDisposable + { + /// + /// Runs pending dispatcher jobs, tied to the test's cancellation token. Headless top-levels + /// post activation, resizes and rendering, so tests have to let those settle between acts. + /// + public void RunJobs(DispatcherPriority? priority = null) => + Dispatcher.UIThread.RunJobs(priority, TestContext.Current.CancellationToken); + + public void Dispose() + { + if (Dispatcher.UIThread.CheckAccess()) + { + Dispatcher.UIThread.RunJobs(); + } + + (AvaloniaLocator.Current.GetService() as ToolTipService)?.Dispose(); + (AvaloniaLocator.Current.GetService() as IDisposable)?.Dispose(); + (AvaloniaLocator.Current.GetService() as IDisposable)?.Dispose(); + + Dispatcher.ResetForUnitTests(); + locatorScope.Dispose(); + Dispatcher.ResetBeforeUnitTests(); + SynchronizationContext.SetSynchronizationContext(oldContext); + } + } +}