浏览代码

Add basic integration tests for NativeControlHost and improve its automation/a11y support. (#15542)

* Added embedding page to IntegrationTestApp.

Currently embeds a lone native text box, and only when running on Windows.

* Win32 automation support for native control host.

Allows native controls to appear in the Avalonia automation tree as a child of the `NativeControlHost`. They also appear in the _wrong_ place - as a direct child of the `Window` - but this appears to be expected behavior as it happens when hosting a win32 control in WPF as well.

* Basic native control integration test on win32.

* Test editing native win32 control in popup.

* Add embedded text box on macOS.

* macOS automation support for native control host.

Implements special-casing of `InteropAutomationPeer`on macOS.

* Make native control integration test work on macOS.

The test for the native control in a popup is disabled on macOS because we have a bug there.

* Add missing parts

* Fix build error

* Skip test to see if CI passes again.

* Log more info about integration tests on win32.

* Try to fix flaky test.

* The tests won't yet work on macOS yet.

Will require #16577.

---------

Co-authored-by: Benedikt Stebner <[email protected]>
Co-authored-by: Max Katz <[email protected]>
Steven Kirk 1 年之前
父节点
当前提交
c6cdbfec3b

+ 8 - 3
native/Avalonia.Native/src/OSX/automation.mm

@@ -13,7 +13,7 @@
     NSMutableArray* _children;
 }
 
-+ (id _Nullable)acquire:(IAvnAutomationPeer *)peer
++ (NSAccessibilityElement *)acquire:(IAvnAutomationPeer *)peer
 {
     if (peer == nullptr)
         return nil;
@@ -23,7 +23,12 @@
     if (instance != nullptr)
         return dynamic_cast<AvnAutomationNode*>(instance)->GetOwner();
     
-    if (peer->IsRootProvider())
+    if (peer->IsInteropPeer())
+    {
+        auto view = (__bridge NSAccessibilityElement*)peer->InteropPeer_GetNativeControlHandle();
+        return view;
+    }
+    else if (peer->IsRootProvider())
     {
         auto window = peer->RootProvider_GetWindow();
         
@@ -35,7 +40,7 @@
         
         auto holder = dynamic_cast<INSViewHolder*>(window);
         auto view = holder->GetNSView();
-        return [view window];
+        return (NSAccessibilityElement*)[view window];
     }
     else
     {

+ 9 - 0
samples/IntegrationTestApp/Embedding/INativeControlFactory.cs

@@ -0,0 +1,9 @@
+using System;
+using Avalonia.Platform;
+
+namespace IntegrationTestApp.Embedding;
+
+internal interface INativeControlFactory
+{
+    IPlatformHandle CreateControl(IPlatformHandle parent, Func<IPlatformHandle> createDefault);
+}

+ 16 - 0
samples/IntegrationTestApp/Embedding/MacHelper.cs

@@ -0,0 +1,16 @@
+using MonoMac.AppKit;
+
+namespace IntegrationTestApp.Embedding;
+
+internal class MacHelper
+{
+    private static bool s_isInitialized;
+
+    public static void EnsureInitialized()
+    {
+        if (s_isInitialized)
+            return;
+        s_isInitialized = true;
+        NSApplication.Init();
+    }
+}

+ 20 - 0
samples/IntegrationTestApp/Embedding/MacOSTextBoxFactory.cs

@@ -0,0 +1,20 @@
+using System;
+using System.Text;
+using Avalonia.Platform;
+using MonoMac.AppKit;
+using MonoMac.WebKit;
+
+namespace IntegrationTestApp.Embedding;
+
+internal class MacOSTextBoxFactory : INativeControlFactory
+{
+    public IPlatformHandle CreateControl(IPlatformHandle parent, Func<IPlatformHandle> createDefault)
+    {
+        MacHelper.EnsureInitialized();
+
+        var textView = new NSTextView();
+        textView.TextStorage.Append(new("Native text box"));
+
+        return new MacOSViewHandle(textView);
+    }
+}

+ 19 - 0
samples/IntegrationTestApp/Embedding/MacOSViewHandle.cs

@@ -0,0 +1,19 @@
+using System;
+using Avalonia.Controls.Platform;
+using MonoMac.AppKit;
+
+namespace IntegrationTestApp.Embedding;
+
+internal class MacOSViewHandle(NSView view) : INativeControlHostDestroyableControlHandle
+{
+    private NSView? _view = view;
+
+    public IntPtr Handle => _view?.Handle ?? IntPtr.Zero;
+    public string HandleDescriptor => "NSView";
+
+    public void Destroy()
+    {
+        _view?.Dispose();
+        _view = null;
+    }
+}

+ 20 - 0
samples/IntegrationTestApp/Embedding/NativeTextBox.cs

@@ -0,0 +1,20 @@
+using Avalonia.Controls;
+using Avalonia.Platform;
+
+namespace IntegrationTestApp.Embedding;
+
+internal class NativeTextBox : NativeControlHost
+{
+    public static INativeControlFactory? Factory { get; set; }
+
+    protected override IPlatformHandle CreateNativeControlCore(IPlatformHandle parent)
+    {
+        return Factory?.CreateControl(parent, () => base.CreateNativeControlCore(parent))
+            ?? base.CreateNativeControlCore(parent); 
+    }
+
+    protected override void DestroyNativeControlCore(IPlatformHandle control)
+    {
+        base.DestroyNativeControlCore(control);
+    }
+}

+ 21 - 0
samples/IntegrationTestApp/Embedding/Win32TextBoxFactory.cs

@@ -0,0 +1,21 @@
+using System;
+using System.Text;
+using Avalonia.Platform;
+
+namespace IntegrationTestApp.Embedding;
+
+internal class Win32TextBoxFactory : INativeControlFactory
+{
+    public IPlatformHandle CreateControl(IPlatformHandle parent, Func<IPlatformHandle> createDefault)
+    {
+        var handle = WinApi.CreateWindowEx(0, "EDIT",
+            @"Native text box",
+            (uint)(WinApi.WindowStyles.WS_CHILD | WinApi.WindowStyles.WS_VISIBLE | WinApi.WindowStyles.WS_BORDER), 
+            0, 0, 1, 1, 
+            parent.Handle,
+            IntPtr.Zero, 
+            WinApi.GetModuleHandle(null), 
+            IntPtr.Zero);
+        return new Win32WindowControlHandle(handle, "HWND");
+    }
+}

+ 11 - 0
samples/IntegrationTestApp/Embedding/Win32WindowControlHandle.cs

@@ -0,0 +1,11 @@
+using System;
+using Avalonia.Controls.Platform;
+using Avalonia.Platform;
+
+namespace IntegrationTestApp.Embedding;
+
+internal class Win32WindowControlHandle : PlatformHandle, INativeControlHostDestroyableControlHandle
+{
+    public Win32WindowControlHandle(IntPtr handle, string descriptor) : base(handle, descriptor) { }
+    public void Destroy() => WinApi.DestroyWindow(Handle);
+}

+ 82 - 0
samples/IntegrationTestApp/Embedding/WinApi.cs

@@ -0,0 +1,82 @@
+using System;
+using System.Runtime.InteropServices;
+
+namespace IntegrationTestApp.Embedding;
+
+internal class WinApi
+{
+    [Flags]
+    public enum WindowStyles : uint
+    {
+        WS_BORDER = 0x800000,
+        WS_CAPTION = 0xc00000,
+        WS_CHILD = 0x40000000,
+        WS_CLIPCHILDREN = 0x2000000,
+        WS_CLIPSIBLINGS = 0x4000000,
+        WS_DISABLED = 0x8000000,
+        WS_DLGFRAME = 0x400000,
+        WS_GROUP = 0x20000,
+        WS_HSCROLL = 0x100000,
+        WS_MAXIMIZE = 0x1000000,
+        WS_MAXIMIZEBOX = 0x10000,
+        WS_MINIMIZE = 0x20000000,
+        WS_MINIMIZEBOX = 0x20000,
+        WS_OVERLAPPED = 0x0,
+        WS_OVERLAPPEDWINDOW = WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_THICKFRAME | WS_MINIMIZEBOX | WS_MAXIMIZEBOX,
+        WS_POPUP = 0x80000000u,
+        WS_POPUPWINDOW = WS_POPUP | WS_BORDER | WS_SYSMENU,
+        WS_SYSMENU = 0x80000,
+        WS_TABSTOP = 0x10000,
+        WS_THICKFRAME = 0x40000,
+        WS_VISIBLE = 0x10000000,
+        WS_VSCROLL = 0x200000,
+        WS_EX_DLGMODALFRAME = 0x00000001,
+        WS_EX_NOPARENTNOTIFY = 0x00000004,
+        WS_EX_NOREDIRECTIONBITMAP = 0x00200000,
+        WS_EX_TOPMOST = 0x00000008,
+        WS_EX_ACCEPTFILES = 0x00000010,
+        WS_EX_TRANSPARENT = 0x00000020,
+        WS_EX_MDICHILD = 0x00000040,
+        WS_EX_TOOLWINDOW = 0x00000080,
+        WS_EX_WINDOWEDGE = 0x00000100,
+        WS_EX_CLIENTEDGE = 0x00000200,
+        WS_EX_CONTEXTHELP = 0x00000400,
+        WS_EX_RIGHT = 0x00001000,
+        WS_EX_LEFT = 0x00000000,
+        WS_EX_RTLREADING = 0x00002000,
+        WS_EX_LTRREADING = 0x00000000,
+        WS_EX_LEFTSCROLLBAR = 0x00004000,
+        WS_EX_RIGHTSCROLLBAR = 0x00000000,
+        WS_EX_CONTROLPARENT = 0x00010000,
+        WS_EX_STATICEDGE = 0x00020000,
+        WS_EX_APPWINDOW = 0x00040000,
+        WS_EX_OVERLAPPEDWINDOW = WS_EX_WINDOWEDGE | WS_EX_CLIENTEDGE,
+        WS_EX_PALETTEWINDOW = WS_EX_WINDOWEDGE | WS_EX_TOOLWINDOW | WS_EX_TOPMOST,
+        WS_EX_LAYERED = 0x00080000,
+        WS_EX_NOINHERITLAYOUT = 0x00100000,
+        WS_EX_LAYOUTRTL = 0x00400000,
+        WS_EX_COMPOSITED = 0x02000000,
+        WS_EX_NOACTIVATE = 0x08000000
+    }
+
+    [DllImport("user32.dll", SetLastError = true)]
+    public static extern bool DestroyWindow(IntPtr hwnd);
+
+    [DllImport("kernel32.dll")]
+    public static extern IntPtr GetModuleHandle(string? lpModuleName);
+
+    [DllImport("user32.dll", SetLastError = true)]
+    public static extern IntPtr CreateWindowEx(
+        int dwExStyle,
+        string lpClassName,
+        string lpWindowName,
+        uint dwStyle,
+        int x,
+        int y,
+        int nWidth,
+        int nHeight,
+        IntPtr hWndParent,
+        IntPtr hMenu,
+        IntPtr hInstance,
+        IntPtr lpParam);
+}

+ 3 - 1
samples/IntegrationTestApp/IntegrationTestApp.csproj

@@ -4,6 +4,7 @@
     <TargetFramework>$(AvsCurrentTargetFramework)</TargetFramework>
     <Nullable>enable</Nullable>
     <NoWarn>$(NoWarn);AVP1012</NoWarn>
+    <ApplicationManifest>app.manifest</ApplicationManifest>
     <IncludeAvaloniaGenerators>true</IncludeAvaloniaGenerators>
   </PropertyGroup>
 
@@ -13,13 +14,14 @@
     <NSHighResolutionCapable>true</NSHighResolutionCapable>
     <CFBundleShortVersionString>1.0.0</CFBundleShortVersionString>
   </PropertyGroup>
-  
+
   <ItemGroup>
     <AvaloniaResource Include="Assets\*" />
   </ItemGroup>
 
   <ItemGroup>
     <PackageReference Include="Dotnet.Bundle" Version="0.9.13" />
+    <PackageReference Include="MonoMac.NetStandard" Version="0.0.4" />
   </ItemGroup>
 
   <ItemGroup>

+ 1 - 0
samples/IntegrationTestApp/MainWindow.axaml.cs

@@ -63,6 +63,7 @@ namespace IntegrationTestApp
                 new("ComboBox", () => new ComboBoxPage()),
                 new("ContextMenu", () => new ContextMenuPage()),
                 new("DesktopPage", () => new DesktopPage()),
+                new("Embedding", () => new EmbeddingPage()),
                 new("Gestures", () => new GesturesPage()),
                 new("ListBox", () => new ListBoxPage()),
                 new("Menu", () => new MenuPage()),

+ 19 - 0
samples/IntegrationTestApp/Pages/EmbeddingPage.axaml

@@ -0,0 +1,19 @@
+<UserControl xmlns="https://github.com/avaloniaui"
+             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+             xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+             xmlns:embedding="using:IntegrationTestApp.Embedding"
+             mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
+             x:Class="IntegrationTestApp.EmbeddingPage">
+  <StackPanel>
+    <embedding:NativeTextBox Name="NativeTextBox" Height="23"/>
+    <StackPanel Orientation="Horizontal">
+      <CheckBox Name="EmbeddingPopupOpenCheckBox">Open Popup</CheckBox>
+      <Popup IsOpen="{Binding #EmbeddingPopupOpenCheckBox.IsChecked}"
+             PlacementTarget="EmbeddingPopupOpenCheckBox"
+             Placement="Right">
+        <embedding:NativeTextBox Name="NativeTextBoxInPopup" Width="200" Height="23"/>
+      </Popup>
+    </StackPanel>
+  </StackPanel>
+</UserControl>

+ 11 - 0
samples/IntegrationTestApp/Pages/EmbeddingPage.axaml.cs

@@ -0,0 +1,11 @@
+using Avalonia.Controls;
+
+namespace IntegrationTestApp;
+
+public partial class EmbeddingPage : UserControl
+{
+    public EmbeddingPage()
+    {
+        InitializeComponent();
+    }
+}

+ 8 - 0
samples/IntegrationTestApp/Program.cs

@@ -1,6 +1,7 @@
 using System;
 using System.Linq;
 using Avalonia;
+using IntegrationTestApp.Embedding;
 
 namespace IntegrationTestApp
 {
@@ -31,6 +32,13 @@ namespace IntegrationTestApp
         public static AppBuilder BuildAvaloniaApp()
             => AppBuilder.Configure<App>()
                 .UsePlatformDetect()
+                .AfterSetup(builder =>
+                {
+                    NativeTextBox.Factory = 
+                        OperatingSystem.IsWindows() ? new Win32TextBoxFactory() :
+                        OperatingSystem.IsMacOS() ? new MacOSTextBoxFactory() :
+                        null;
+                })
                 .LogToTrace();
     }
 }

+ 28 - 0
samples/IntegrationTestApp/app.manifest

@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="utf-8"?>
+<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
+  <assemblyIdentity version="1.0.0.0" name="ControlCatalog.app"/>
+
+  <compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
+    <application>
+      <!-- A list of the Windows versions that this application has been tested on
+           and is designed to work with. Uncomment the appropriate elements
+           and Windows will automatically select the most compatible environment. -->
+
+      <!-- Windows Vista -->
+      <!--<supportedOS Id="{e2011457-1546-43c5-a5fe-008deee3d3f0}" />-->
+
+      <!-- Windows 7 -->
+      <!--<supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}" />-->
+
+      <!-- Windows 8 -->
+      <!--<supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}" />-->
+
+      <!-- Windows 8.1 -->
+      <!--<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}" />-->
+
+      <!-- Windows 10 -->
+      <supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
+
+    </application>
+  </compatibility>
+</assembly>

+ 46 - 0
src/Avalonia.Controls/Automation/Peers/InteropAutomationPeer.cs

@@ -0,0 +1,46 @@
+using System;
+using System.Collections.Generic;
+using Avalonia.Automation.Peers;
+using Avalonia.Platform;
+
+namespace Avalonia.Controls.Automation.Peers;
+
+/// <summary>
+/// Represents the root of a native control automation tree hosted by a <see cref="NativeControlHost"/>.
+/// </summary>
+/// <remarks>
+/// This peer should be special-cased in the platform backend, as it represents a native control
+/// and hence none of the standard automation peer methods are applicable.
+/// </remarks>
+internal class InteropAutomationPeer : AutomationPeer
+{
+    private AutomationPeer? _parent;
+
+    public InteropAutomationPeer(IPlatformHandle nativeControlHandle) => NativeControlHandle = nativeControlHandle;
+    public IPlatformHandle NativeControlHandle { get; }
+
+    protected override void BringIntoViewCore() => throw new NotImplementedException();
+    protected override string? GetAcceleratorKeyCore() => throw new NotImplementedException();
+    protected override string? GetAccessKeyCore() => throw new NotImplementedException();
+    protected override AutomationControlType GetAutomationControlTypeCore() => throw new NotImplementedException();
+    protected override string? GetAutomationIdCore() => throw new NotImplementedException();
+    protected override Rect GetBoundingRectangleCore() => throw new NotImplementedException();
+    protected override string GetClassNameCore() => throw new NotImplementedException();
+    protected override AutomationPeer? GetLabeledByCore() => throw new NotImplementedException();
+    protected override string? GetNameCore() => throw new NotImplementedException();
+    protected override IReadOnlyList<AutomationPeer> GetOrCreateChildrenCore() => throw new NotImplementedException();
+    protected override AutomationPeer? GetParentCore() => _parent;
+    protected override bool HasKeyboardFocusCore() => throw new NotImplementedException();
+    protected override bool IsContentElementCore() => throw new NotImplementedException();
+    protected override bool IsControlElementCore() => throw new NotImplementedException();
+    protected override bool IsEnabledCore() => throw new NotImplementedException();
+    protected override bool IsKeyboardFocusableCore() => throw new NotImplementedException();
+    protected override void SetFocusCore() => throw new NotImplementedException();
+    protected override bool ShowContextMenuCore() => throw new NotImplementedException();
+
+    protected internal override bool TrySetParent(AutomationPeer? parent)
+    {
+        _parent = parent;
+        return true;
+    }
+}

+ 23 - 0
src/Avalonia.Controls/Automation/Peers/NativeControlHostPeer.cs

@@ -0,0 +1,23 @@
+using System;
+using System.Collections.Generic;
+using Avalonia.Automation.Peers;
+
+namespace Avalonia.Controls.Automation.Peers;
+
+internal class NativeControlHostPeer : ControlAutomationPeer
+{
+    public NativeControlHostPeer(NativeControlHost owner)
+        : base(owner)
+    {
+        owner.NativeControlHandleChanged += OnNativeControlHandleChanged;
+    }
+
+    protected override IReadOnlyList<AutomationPeer>? GetChildrenCore()
+    {
+        if (Owner is NativeControlHost host && host.NativeControlHandle != null)
+            return [new InteropAutomationPeer(host.NativeControlHandle)];
+        return null;
+    }
+
+    private void OnNativeControlHandleChanged(object? sender, EventArgs e) => InvalidateChildren();
+}

+ 28 - 11
src/Avalonia.Controls/NativeControlHost.cs

@@ -1,8 +1,9 @@
 using System;
 using System.Collections.Generic;
 using System.Diagnostics;
+using Avalonia.Automation.Peers;
+using Avalonia.Controls.Automation.Peers;
 using Avalonia.Controls.Platform;
-using Avalonia.Media;
 using Avalonia.Platform;
 using Avalonia.Threading;
 using Avalonia.VisualTree;
@@ -24,6 +25,21 @@ namespace Avalonia.Controls
             FlowDirectionProperty.Changed.AddClassHandler<NativeControlHost>(OnFlowDirectionChanged);
         }
 
+        internal IPlatformHandle? NativeControlHandle
+        {
+            get => _nativeControlHandle;
+            set
+            {
+                if (_nativeControlHandle != value)
+                {
+                    _nativeControlHandle = value;
+                    NativeControlHandleChanged?.Invoke(this, EventArgs.Empty);
+                }
+            }
+        }
+
+        internal event EventHandler? NativeControlHandleChanged;
+
         /// <inheritdoc />
         protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
         {
@@ -89,19 +105,19 @@ namespace Avalonia.Controls
 
                 // If there is no attachment, but the control exists,
                 // attempt to attach to the current toplevel or destroy the control if it's incompatible
-                if (_attachment == null && _nativeControlHandle != null)
+                if (_attachment == null && NativeControlHandle != null)
                 {
-                    if (_currentHost.IsCompatibleWith(_nativeControlHandle))
-                        _attachment = _currentHost.CreateNewAttachment(_nativeControlHandle);
+                    if (_currentHost.IsCompatibleWith(NativeControlHandle))
+                        _attachment = _currentHost.CreateNewAttachment(NativeControlHandle);
                     else
                         DestroyNativeControl();
                 }
 
                 // There is no control handle an no attachment, create both
-                if (_nativeControlHandle == null)
+                if (NativeControlHandle == null)
                 {
                     _attachment = _currentHost.CreateNewAttachment(parent =>
-                        _nativeControlHandle = CreateNativeControlCore(parent));
+                        NativeControlHandle = CreateNativeControlCore(parent));
                 }
             }
             else
@@ -111,7 +127,7 @@ namespace Avalonia.Controls
                     _attachment.AttachedTo = null;
                 
                 // Don't destroy the control immediately, it might be just being reparented to another TopLevel
-                if (_nativeControlHandle != null && !_queuedForDestruction)
+                if (NativeControlHandle != null && !_queuedForDestruction)
                 {
                     _queuedForDestruction = true;
                     Dispatcher.UIThread.Post(CheckDestruction, DispatcherPriority.Background);
@@ -180,13 +196,13 @@ namespace Avalonia.Controls
 
         private void DestroyNativeControl()
         {
-            if (_nativeControlHandle != null)
+            if (NativeControlHandle != null)
             {
                 _attachment?.Dispose();
                 _attachment = null;
                 
-                DestroyNativeControlCore(_nativeControlHandle);
-                _nativeControlHandle = null;
+                DestroyNativeControlCore(NativeControlHandle);
+                NativeControlHandle = null;
             }
         }
 
@@ -197,6 +213,7 @@ namespace Avalonia.Controls
                 nativeControlHostDestroyableControlHandle.Destroy();
             }
         }
-        
+
+        protected override AutomationPeer OnCreateAutomationPeer() => new NativeControlHostPeer(this);
     }
 }

+ 4 - 0
src/Avalonia.Native/AvnAutomationPeer.cs

@@ -7,6 +7,7 @@ using Avalonia.Automation;
 using Avalonia.Automation.Peers;
 using Avalonia.Automation.Provider;
 using Avalonia.Controls;
+using Avalonia.Controls.Automation.Peers;
 using Avalonia.Native.Interop;
 
 #nullable enable
@@ -56,6 +57,9 @@ namespace Avalonia.Native
             Node = node;
         }
 
+        public int IsInteropPeer() => (_inner is InteropAutomationPeer).AsComBool();
+        public IntPtr InteropPeer_GetNativeControlHandle() => ((InteropAutomationPeer)_inner).NativeControlHandle.Handle;
+        
         public IAvnAutomationPeer? RootPeer
         {
             get

+ 3 - 0
src/Avalonia.Native/avn.idl

@@ -1180,6 +1180,9 @@ interface IAvnAutomationPeer : IUnknown
 
      IAvnAutomationPeer* GetRootPeer();
      
+     bool IsInteropPeer();
+     [intptr]void* InteropPeer_GetNativeControlHandle();
+          
      bool IsRootProvider();
      IAvnWindowBase* RootProvider_GetWindow();
      IAvnAutomationPeer* RootProvider_GetFocus();

+ 9 - 5
src/Windows/Avalonia.Win32/Automation/AutomationNode.cs

@@ -8,6 +8,7 @@ using System.Runtime.CompilerServices;
 using System.Runtime.InteropServices;
 using Avalonia.Automation;
 using Avalonia.Automation.Peers;
+using Avalonia.Controls.Automation.Peers;
 using Avalonia.Threading;
 using Avalonia.Win32.Interop.Automation;
 using AAP = Avalonia.Automation.Provider;
@@ -63,7 +64,7 @@ namespace Avalonia.Win32.Automation
 
         public AutomationPeer Peer { get; protected set; }
 
-        public Rect BoundingRectangle
+        public virtual Rect BoundingRectangle
         {
             get => InvokeSync(() =>
             {
@@ -79,7 +80,7 @@ namespace Avalonia.Win32.Automation
         }
 
         public virtual IRawElementProviderSimple? HostRawElementProvider => null;
-        public ProviderOptions ProviderOptions => ProviderOptions.ServerSideProvider;
+        public virtual ProviderOptions ProviderOptions => ProviderOptions.ServerSideProvider;
 
         [return: MarshalAs(UnmanagedType.IUnknown)]
         public virtual object? GetPatternProvider(int patternId)
@@ -275,9 +276,12 @@ namespace Avalonia.Win32.Automation
 
         private static AutomationNode Create(AutomationPeer peer)
         {
-            return peer.GetProvider<AAP.IRootProvider>() is object ?
-                new RootAutomationNode(peer) :
-                new AutomationNode(peer);
+            if (peer is InteropAutomationPeer interop)
+                return new InteropAutomationNode(interop);
+            else if (peer.GetProvider<AAP.IRootProvider>() is not null)
+                return new RootAutomationNode(peer);
+            else 
+                return new AutomationNode(peer);
         }
 
         private static UiaControlTypeId ToUiaControlType(AutomationControlType role)

+ 48 - 0
src/Windows/Avalonia.Win32/Automation/InteropAutomationNode.cs

@@ -0,0 +1,48 @@
+using System;
+using System.Diagnostics.CodeAnalysis;
+using System.Runtime.InteropServices;
+using Avalonia.Controls.Automation.Peers;
+using Avalonia.Win32.Interop.Automation;
+
+namespace Avalonia.Win32.Automation;
+
+/// <summary>
+/// An automation node which serves as the root of an embedded native control automation tree.
+/// </summary>
+[RequiresUnreferencedCode("Requires .NET COM interop")]
+internal class InteropAutomationNode : AutomationNode, IRawElementProviderFragmentRoot
+{
+    private readonly IntPtr _handle;
+
+    public InteropAutomationNode(InteropAutomationPeer peer)
+        : base(peer)
+    {
+        _handle = peer.NativeControlHandle.Handle;
+    }
+
+    public override Rect BoundingRectangle => default;
+    public override IRawElementProviderFragmentRoot? FragmentRoot => null;
+    public override ProviderOptions ProviderOptions => ProviderOptions.ServerSideProvider | ProviderOptions.OverrideProvider;
+
+    public override object? GetPatternProvider(int patternId) => null;
+    public override object? GetPropertyValue(int propertyId) => null;
+
+    public override IRawElementProviderSimple? HostRawElementProvider
+    {
+        get
+        {
+            var hr = UiaCoreProviderApi.UiaHostProviderFromHwnd(_handle, out var result);
+            Marshal.ThrowExceptionForHR(hr);
+            return result;
+        }
+    }
+
+    public override IRawElementProviderFragment? Navigate(NavigateDirection direction)
+    {
+        return direction == NavigateDirection.Parent ? base.Navigate(direction) : null;
+    }
+
+    public IRawElementProviderFragment? ElementProviderFromPoint(double x, double y) => null;
+    public IRawElementProviderFragment? GetFocus() => null;
+    public IRawElementProviderSimple[]? GetEmbeddedFragmentRoots() => null;
+}

+ 64 - 0
tests/Avalonia.IntegrationTests.Appium/EmbeddingTests.cs

@@ -0,0 +1,64 @@
+using System;
+using Xunit;
+
+namespace Avalonia.IntegrationTests.Appium
+{
+    [Collection("Default")]
+    public class EmbeddingTests : TestBase
+    {
+        public EmbeddingTests(DefaultAppFixture fixture)
+            : base(fixture, "Embedding")
+        {
+        }
+
+        [PlatformFact(TestPlatforms.Windows, "Not yet working on macOS")]
+        public void Can_Edit_Native_TextBox()
+        {
+            // Appium has different XPath syntax between Windows and macOS.
+            var textBox = OperatingSystem.IsWindows() ?
+                Session.FindElementByXPath($"//*[@AutomationId='NativeTextBox']//*[1]") :
+                Session.FindElementByXPath($"//*[@identifier='NativeTextBox']//*[1]");
+
+            Assert.Equal("Native text box", textBox.Text);
+
+            textBox.SendKeys("Hello world!");
+
+            // SendKeys behaves differently between Windows and macOS. On Windows it inserts at the start
+            // of the text box, on macOS it replaces the text for some reason. Sigh.
+            var expected = OperatingSystem.IsWindows() ?
+                "Hello world!Native text box" :
+                "Hello world!";
+            Assert.Equal(expected, textBox.Text);
+        }
+
+        [PlatformFact(TestPlatforms.Windows, "Not yet working on macOS")]
+        public void Can_Edit_Native_TextBox_In_Popup()
+        {
+            var checkBox = Session.FindElementByAccessibilityId("EmbeddingPopupOpenCheckBox");
+            checkBox.Click();
+
+            try
+            {
+                // Appium has different XPath syntax between Windows and macOS.
+                var textBox = OperatingSystem.IsWindows() ?
+                    Session.FindElementByXPath($"//*[@AutomationId='NativeTextBoxInPopup']//*[1]") :
+                    Session.FindElementByXPath($"//*[@identifier='NativeTextBoxInPopup']//*[1]");
+
+                Assert.Equal("Native text box", textBox.Text);
+
+                textBox.SendKeys("Hello world!");
+
+                // SendKeys behaves differently between Windows and macOS. On Windows it inserts at the start
+                // of the text box, on macOS it replaces the text for some reason. Sigh.
+                var expected = OperatingSystem.IsWindows() ?
+                    "Hello world!Native text box" :
+                    "Hello world!";
+                Assert.Equal(expected, textBox.Text);
+            }
+            finally
+            {
+                checkBox.Click();
+            }
+        }
+    }
+}