diff --git a/.github/README.md b/.github/README.md
index 3cbed40..01f4e53 100644
--- a/.github/README.md
+++ b/.github/README.md
@@ -34,6 +34,12 @@
Installation
+
+ Usage
+
+
Contributing
License
@@ -47,14 +53,16 @@ WinUia is a .NET library for inspecting and automating Windows applications thro
### Features
-* **Feature One:** Describe a core responsibility or benefit here.
-* **Feature Two:** Highlight another modular component or capability.
-* **Modern Architecture:** Built with SOLID principles and design patterns for maximum maintainability.
+* **No dependencies:** talks to the native UIA3 COM API (`UIAutomationCore.dll`) through hand-written interop. No FlaUI, no interop packages.
+* **Works across UI stacks:** WinForms, WPF, WinUI 3 (packaged and unpackaged), UWP and classic Win32 apps, x86 or x64.
+* **Self-healing elements:** an element remembers how it was found and re-finds itself when a re-render makes it stale.
+* **Waiting built in:** `Find` polls until the element appears (default 5 s), `TryFind` returns `null` instead of throwing.
+* **Patterns first, mouse last:** `Click()` and `SetValue()` use UIA patterns (Invoke, Toggle, SelectionItem, ExpandCollapse, Value) and only fall back to `SendInput`.
### Built With
-* [Framework/Language Name](https://example.com)
-* [Library Name](https://example.com)
+* [.NET 10](https://dotnet.microsoft.com/)
+* [Microsoft UI Automation](https://learn.microsoft.com/windows/win32/winauto/entry-uiauto-win32)
## Getting Started
Setting up WinUia on your local machine is straightforward. Make sure the [.NET 10 SDK](https://dotnet.microsoft.com/download) is installed.
@@ -80,6 +88,81 @@ Setting up WinUia on your local machine is straightforward. Make sure the [.NET
dotnet test WinUia.slnx
```
+ The UI tests (`Category=UI`) launch an app (the empty `tests/WinUia.TestApp`, or `examples/WinUia.Examples.Winforms` for the examples) and need an interactive desktop. Run only the others with `dotnet test WinUia.slnx --filter "Category!=UI"`.
+
+## Usage
+
+```csharp
+using WinUia;
+using WinUia.Core;
+
+using var app = App.Launch(@"C:\path\to\MyApp.exe"); // or App.LaunchPackaged("Publisher.App_hash!App"), App.Attach(pid)
+
+app.Find("txtName").SetValue("Ada"); // by AutomationId, then by Name
+app.Find("btnSave").Click();
+
+var status = app.MainWindow.FindByAutomationId("lblStatus", TimeSpan.FromSeconds(10));
+Console.WriteLine(status.Name);
+
+var dialog = app.FindWindow("Save changes?"); // a dialog or other window of the app
+dialog.Find(e => e.ControlType == ControlType.Button && e.Name == "Yes").Click();
+// Disposing a launched app closes it (Window pattern first, then kill).
+```
+
+`Element`'s own methods (`Click`, `SetValue`, `GetText`, `Toggle`, `Select`, `Expand`, `Collapse`) are the everyday API:
+they check the element is enabled and pick the right pattern. The pattern wrappers (`element.TogglePattern.State`,
+`element.WindowPattern.Close()`, ...) are for reading pattern state and for the members those methods do not cover.
+
+A page object for the whole application derives from `App` and lists its controls; `As()` turns a launched or
+attached app into it, FlaUI-style. Page objects for part of the UI are plain classes built on an `Element`
+(see `examples/WinUia.Examples.Winforms.Tests`):
+
+```csharp
+public sealed class MainPage : App
+{
+ public Element SaveButton => MainWindow.FindByAutomationId("btnSave");
+ public SaveDialog Save() { SaveButton.Click(); return new SaveDialog(FindWindow("Save changes?")); }
+}
+
+using var page = App.Launch(@"C:\path\to\MyApp.exe").As(); // disposing the page closes the app
+```
+
+### Project structure
+
+WinUia is a modular monolith: one solution, one project per module, and one test project per module. Dependencies point one way, from the application layer down to the platform:
+
+| Module | Layer | Contents | Depends on |
+|---|---|---|---|
+| `WinUia` | Application | `App` (launch, attach, find, close), `AppProcessException`; internal launchers (`WinUia.Launchers`) | `WinUia.Core` (public API only) |
+| `WinUia.Core` | Automation | `AutomationContext`, `Element` (lambda searches such as `Find(e => e.Name == "OK")`, self-healing locators), `Poll`, `WinUia.Core.Patterns`, `WinUia.Core.Exceptions`, the UIA COM interop | `WinUia.Input` (public API only) |
+| `WinUia.Input` | Platform | `Win32InputSimulator` (`SendInput`), `PhysicalDpi`, `NativeButton` | — |
+| `WinUia.NUnit` | Test integration | `[UiTest]`: one desktop per test, across test processes; `Eventually(...)` for asynchronous UI state | NUnit, `WinUia.Core` |
+
+Module rules:
+
+* Each module's `Interop/` folder is private to that module. Other modules use what it offers (`PhysicalDpi`, `NativeButton`), never its P/Invoke or COM declarations.
+* Modules use each other's public API only, the same API an application built on WinUia gets.
+* Internals are shared only through `InternalsVisibleTo`, and only with the module's own `.UnitTests` project.
+* Namespaces follow project and folder. One type per file.
+* Package versions are set once, in `Directory.Packages.props`.
+
+Tests live next to their module as `tests/.UnitTests`. The ones that drive a real application launch
+`tests/WinUia.TestApp`, an empty window, located through `tests/WinUia.Testing.Shared` (`AppPaths`). Tests of control
+behaviour (patterns, searches, self-healing elements, dialogs, input) run against the WinForms example instead, in
+`examples/WinUia.Examples.Winforms.Tests`. UI tests are marked `[UiTest]`
+(category `UI`). Tests use NUnit.
+
+`examples/` shows WinUia the way a user would use it: `WinUia.Examples.Winforms` is a WinForms app and
+`WinUia.Examples.Winforms.Tests` tests it with page objects, using only WinUia's public API and `WinUia.NUnit`, nothing
+from `tests/`. Examples and tests are independent: neither references the other.
+
+### Limitations
+
+* **MTA only:** UI Automation must be called from an MTA thread. Creating an `AutomationContext` or `Automation` on an STA thread (for example a WinForms/WPF UI thread, or an `[STAThread]` `Main`) throws. Use `Task.Run` or a thread-pool thread.
+* **Physical input needs a real desktop:** the mouse and keyboard fallbacks use `SendInput`, which does nothing on a locked workstation or a disconnected RDP session, and goes to whatever window is on top.
+* **Elevation (UIPI):** a non-elevated process cannot send input to, and has limited UIA access to, an elevated app. Run the automation elevated when the target is.
+* **DPI:** creating an `AutomationContext` makes the process per-monitor DPI aware (if it is not already) so UIA coordinates and `SendInput` both use physical pixels.
+
## Contributing
diff --git a/.github/workflows/build-and-test.yaml b/.github/workflows/build-and-test.yaml
index d9b1bbf..62981bb 100644
--- a/.github/workflows/build-and-test.yaml
+++ b/.github/workflows/build-and-test.yaml
@@ -5,30 +5,31 @@ on:
workflow_dispatch:
workflow_call: {}
+# One run per pull request: a new commit cancels the run still in progress for the previous one.
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+# WinUia is Windows-only (UI Automation COM, SendInput), so every job runs on Windows.
+# build compiles once; unit-test and ui-test run in parallel on its output.
jobs:
build:
- runs-on: ${{ matrix.os }}
- strategy:
- matrix:
- os: [ubuntu-latest, windows-latest, macos-latest]
+ name: Build
+ runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v7
- - run: echo ${{matrix.os}}
-
- name: Setup .NET SDK
uses: actions/setup-dotnet@v6
with:
- dotnet-version: '10.0.x'
+ dotnet-version: '10.0.x'
- name: Load nuget cache
uses: actions/cache@v6
with:
- path: |
- ~/.nuget/packages
- !~/.nuget/packages/unwanted
- key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json') }}
+ path: ~/.nuget/packages
+ key: ${{ runner.os }}-nuget-${{ hashFiles('**/Directory.Packages.props', '**/*.csproj', '**/Directory.Build.props') }}
restore-keys: |
${{ runner.os }}-nuget-
@@ -38,5 +39,92 @@ jobs:
- name: Build
run: dotnet build --no-restore --configuration Release
- - name: Test
- run: dotnet test --no-build --configuration Release --logger GitHubActions
+ - name: Upload build output
+ uses: actions/upload-artifact@v7
+ with:
+ name: build-output
+ path: |
+ src/*/bin/Release
+ tests/*/bin/Release
+ examples/*/bin/Release
+ if-no-files-found: error
+ retention-days: 1
+
+ unit-test:
+ name: Run unit tests
+ needs: build
+ runs-on: windows-latest
+ timeout-minutes: 15
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v7
+
+ - name: Setup .NET SDK
+ uses: actions/setup-dotnet@v6
+ with:
+ dotnet-version: '10.0.x'
+
+ - name: Load nuget cache
+ uses: actions/cache@v6
+ with:
+ path: ~/.nuget/packages
+ key: ${{ runner.os }}-nuget-${{ hashFiles('**/Directory.Packages.props', '**/*.csproj', '**/Directory.Build.props') }}
+ restore-keys: |
+ ${{ runner.os }}-nuget-
+
+ # dotnet test needs the restored project assets to find the test projects; the binaries come from build.
+ - name: Install dependencies
+ run: dotnet restore
+
+ - name: Download build output
+ uses: actions/download-artifact@v8
+ with:
+ name: build-output
+
+ - name: Unit test
+ run: dotnet test --no-build --configuration Release --filter "Category!=UI" --logger GitHubActions
+
+ ui-test:
+ name: Run UI tests
+ # UI tests ([UiTest], category UI) launch apps and drive them through UI Automation and SendInput, so they need an
+ # interactive desktop session, which GitHub-hosted Windows runners have.
+ needs: build
+ runs-on: windows-latest
+ timeout-minutes: 20
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v7
+
+ - name: Setup .NET SDK
+ uses: actions/setup-dotnet@v6
+ with:
+ dotnet-version: '10.0.x'
+
+ - name: Load nuget cache
+ uses: actions/cache@v6
+ with:
+ path: ~/.nuget/packages
+ key: ${{ runner.os }}-nuget-${{ hashFiles('**/Directory.Packages.props', '**/*.csproj', '**/Directory.Build.props') }}
+ restore-keys: |
+ ${{ runner.os }}-nuget-
+
+ # dotnet test needs the restored project assets to find the test projects; the binaries come from build.
+ - name: Install dependencies
+ run: dotnet restore
+
+ - name: Download build output
+ uses: actions/download-artifact@v8
+ with:
+ name: build-output
+
+ - name: UI test
+ run: >-
+ dotnet test --no-build --configuration Release --filter "Category=UI"
+ --logger GitHubActions --logger trx --results-directory TestResults
+
+ - name: Upload UI test results
+ if: failure()
+ uses: actions/upload-artifact@v7
+ with:
+ name: ui-test-results
+ path: TestResults
diff --git a/.gitignore b/.gitignore
index 7531c1d..584aa97 100644
--- a/.gitignore
+++ b/.gitignore
@@ -59,4 +59,7 @@ nunit-*.xml
*.env.staging
*.idea
-.vs/
\ No newline at end of file
+.vs/
+
+# User-specific IDE files
+*.user
diff --git a/Directory.Build.props b/Directory.Build.props
new file mode 100644
index 0000000..cea0c5d
--- /dev/null
+++ b/Directory.Build.props
@@ -0,0 +1,10 @@
+
+
+ net10.0-windows
+ enable
+ enable
+ true
+
+ true
+
+
diff --git a/Directory.Packages.props b/Directory.Packages.props
new file mode 100644
index 0000000..49055e0
--- /dev/null
+++ b/Directory.Packages.props
@@ -0,0 +1,14 @@
+
+
+
+ true
+
+
+
+
+
+
+
+
+
+
diff --git a/WinUia.slnx b/WinUia.slnx
index 4e2253d..86ccf17 100644
--- a/WinUia.slnx
+++ b/WinUia.slnx
@@ -1 +1,20 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/Directory.Build.props b/examples/Directory.Build.props
new file mode 100644
index 0000000..221014c
--- /dev/null
+++ b/examples/Directory.Build.props
@@ -0,0 +1,11 @@
+
+
+
+
+
+ false
+
+
diff --git a/examples/WinUia.Examples.Winforms.Tests/Application/MainForm.cs b/examples/WinUia.Examples.Winforms.Tests/Application/MainForm.cs
new file mode 100644
index 0000000..64d53fd
--- /dev/null
+++ b/examples/WinUia.Examples.Winforms.Tests/Application/MainForm.cs
@@ -0,0 +1,46 @@
+using WinUia.Core;
+
+namespace WinUia.Examples.Winforms.Tests.Application;
+
+///
+/// Page object for the example app: the itself, with the controls of its main window. Get it with
+/// App.LaunchApplication() (). The tab pages and the dialog are page objects
+/// of their own, built on the elements they cover.
+///
+public sealed class MainForm : App
+{
+ public Element Window => MainWindow;
+ public Element Button => Window.FindByAutomationId("btnClick");
+ public Element ResultLabel => Window.FindByAutomationId("lblResult");
+ public Element InputBox => Window.FindByAutomationId("txtInput");
+ public Element ToggleCheckBox => Window.FindByAutomationId("chkToggle");
+ public Element ItemsList => Window.FindByAutomationId("lstItems");
+ public Element RecreateButton => Window.FindByAutomationId("btnRecreate");
+ public Element VolatileButton => Window.FindByAutomationId("btnVolatile");
+ public Element ItemsPanel => Window.FindByAutomationId("pnlItems");
+ public Element ReverseButton => Window.FindByAutomationId("btnReverse");
+ public Element OpenDialogButton => Window.FindByAutomationId("btnOpenDialog");
+ public Element TabControl => Window.FindByAutomationId("tabMain");
+
+ public DialogForm OpenDialog()
+ {
+ OpenDialogButton.Click();
+ return new DialogForm(FindWindow("WinUia Dialog"));
+ }
+
+ // The tab header (TabItem) and its page (Pane) share the tab's text, so ask for the TabItem explicitly.
+ public Element TabHeader(string text) =>
+ TabControl.Find(e => e.Name == text && e.ControlType == ControlType.TabItem, TreeScope.Children);
+
+ public Tab1 SelectTab1()
+ {
+ TabHeader("Tab 1").Select();
+ return new Tab1(Window);
+ }
+
+ public Tab2 SelectTab2()
+ {
+ TabHeader("Tab 2").Select();
+ return new Tab2(Window);
+ }
+}
diff --git a/examples/WinUia.Examples.Winforms.Tests/Application/MainForm/DialogForm.cs b/examples/WinUia.Examples.Winforms.Tests/Application/MainForm/DialogForm.cs
new file mode 100644
index 0000000..a28e525
--- /dev/null
+++ b/examples/WinUia.Examples.Winforms.Tests/Application/MainForm/DialogForm.cs
@@ -0,0 +1,13 @@
+using WinUia.Core;
+
+
+namespace WinUia.Examples.Winforms.Tests.Application;
+
+public sealed class DialogForm(Element window)
+{
+ public Element Window { get; } = window;
+ public Element MessageLabel => Window.FindByAutomationId("lblDialogMessage");
+ public Element InputBox => Window.FindByAutomationId("txtDialogInput");
+ public Element OkButton => Window.FindByAutomationId("btnDialogOk");
+ public Element CancelButton => Window.FindByAutomationId("btnDialogCancel");
+}
diff --git a/examples/WinUia.Examples.Winforms.Tests/Application/MainForm/Tab1.cs b/examples/WinUia.Examples.Winforms.Tests/Application/MainForm/Tab1.cs
new file mode 100644
index 0000000..847eef1
--- /dev/null
+++ b/examples/WinUia.Examples.Winforms.Tests/Application/MainForm/Tab1.cs
@@ -0,0 +1,9 @@
+using WinUia.Core;
+
+namespace WinUia.Examples.Winforms.Tests.Application;
+
+/// The content of the first tab page, found under the main window.
+public sealed class Tab1(Element window)
+{
+ public Element Content => window.FindByAutomationId("lblTab1");
+}
diff --git a/examples/WinUia.Examples.Winforms.Tests/Application/MainForm/Tab2.cs b/examples/WinUia.Examples.Winforms.Tests/Application/MainForm/Tab2.cs
new file mode 100644
index 0000000..43a2bbe
--- /dev/null
+++ b/examples/WinUia.Examples.Winforms.Tests/Application/MainForm/Tab2.cs
@@ -0,0 +1,9 @@
+using WinUia.Core;
+
+namespace WinUia.Examples.Winforms.Tests.Application;
+
+/// The content of the second tab page, found under the main window.
+public sealed class Tab2(Element window)
+{
+ public Element Content => window.FindByAutomationId("lblTab2");
+}
diff --git a/examples/WinUia.Examples.Winforms.Tests/Extensions/AppExtensions.cs b/examples/WinUia.Examples.Winforms.Tests/Extensions/AppExtensions.cs
new file mode 100644
index 0000000..328af40
--- /dev/null
+++ b/examples/WinUia.Examples.Winforms.Tests/Extensions/AppExtensions.cs
@@ -0,0 +1,19 @@
+using System.Reflection;
+using WinUia.Examples.Winforms.Tests.Application;
+
+namespace WinUia.Examples.Winforms.Tests.Extensions;
+
+/// Launching the example app, as a static extension on : App.LaunchApplication().
+public static class AppExtensions
+{
+ // Stamped in by the .csproj: the example app's exe next to this project.
+ private static readonly string ApplicationPath =
+ typeof(AppExtensions).Assembly.GetCustomAttributes()
+ .Single(a => a.Key == "ApplicationPath").Value!;
+
+ extension(App)
+ {
+ /// Launches the example app as its ; dispose that to close it.
+ public static MainForm LaunchApplication() => App.Launch(ApplicationPath).As();
+ }
+}
diff --git a/examples/WinUia.Examples.Winforms.Tests/Tests/DialogTests.cs b/examples/WinUia.Examples.Winforms.Tests/Tests/DialogTests.cs
new file mode 100644
index 0000000..663d184
--- /dev/null
+++ b/examples/WinUia.Examples.Winforms.Tests/Tests/DialogTests.cs
@@ -0,0 +1,81 @@
+using System.Diagnostics;
+using WinUia.Core.Exceptions;
+using WinUia.Examples.Winforms.Tests.Application;
+using WinUia.Examples.Winforms.Tests.Extensions;
+
+namespace WinUia.Examples.Winforms.Tests.Tests;
+
+[UiTest]
+public sealed class DialogTests
+{
+ private MainForm _mainForm = null!;
+
+ [SetUp]
+ public void SetUp() => _mainForm = App.LaunchApplication();
+
+ [Test]
+ public void Opening_the_dialog_shows_a_modal_window()
+ {
+ var stopwatch = Stopwatch.StartNew();
+ var dialog = _mainForm.OpenDialog();
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(stopwatch.Elapsed, Is.LessThan(TimeSpan.FromSeconds(5)), "Click must not block while the modal dialog is open");
+ Assert.That(dialog.Window.Name, Is.EqualTo("WinUia Dialog"));
+ Assert.That(dialog.Window.WindowPattern.IsModal, Is.True);
+ Assert.That(dialog.MessageLabel.Name, Is.EqualTo("Enter a value:"));
+ }
+ }
+
+ [Test]
+ public void TryFindWindow_returns_null_for_a_window_that_does_not_exist()
+ {
+ Assert.That(_mainForm.TryFindWindow("No such window", TimeSpan.FromMilliseconds(100)), Is.Null);
+ }
+
+ [Test]
+ public void Confirming_the_dialog_reports_the_entered_value()
+ {
+ var dialog = _mainForm.OpenDialog();
+
+ dialog.InputBox.SetValue("hello");
+ dialog.OkButton.Click();
+
+ Eventually(() => _mainForm.ResultLabel.Name == "Dialog: OK (hello)");
+ }
+
+ [Test]
+ public void Cancelling_the_dialog_reports_cancel()
+ {
+ var dialog = _mainForm.OpenDialog();
+
+ dialog.CancelButton.Click();
+
+ Eventually(() => _mainForm.ResultLabel.Name == "Dialog: Cancel");
+ }
+
+ [Test]
+ public void Closing_the_dialog_removes_it()
+ {
+ var dialog = _mainForm.OpenDialog();
+
+ dialog.CancelButton.Click();
+
+ Eventually(() =>
+ {
+ try
+ {
+ _ = dialog.Window.Name;
+ return false;
+ }
+ catch (UiaStaleElementException)
+ {
+ return true; // Closed: the window is gone and cannot be found again.
+ }
+ });
+ }
+
+ [TearDown]
+ public void TearDown() => _mainForm.Dispose();
+}
diff --git a/examples/WinUia.Examples.Winforms.Tests/Tests/MainFormTests.cs b/examples/WinUia.Examples.Winforms.Tests/Tests/MainFormTests.cs
new file mode 100644
index 0000000..d51bd8d
--- /dev/null
+++ b/examples/WinUia.Examples.Winforms.Tests/Tests/MainFormTests.cs
@@ -0,0 +1,108 @@
+using WinUia.Core;
+using WinUia.Core.Patterns;
+using WinUia.Examples.Winforms.Tests.Application;
+using WinUia.Examples.Winforms.Tests.Extensions;
+
+namespace WinUia.Examples.Winforms.Tests.Tests;
+
+[UiTest]
+public sealed class MainFormTests
+{
+ private MainForm _mainForm = null!;
+
+ [SetUp]
+ public void SetUp() => _mainForm = App.LaunchApplication();
+
+ [Test]
+ public void Launch_returns_the_main_form()
+ {
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(_mainForm.Window.Name, Is.EqualTo("WinUia Test App"));
+ Assert.That(_mainForm.Window.ProcessId, Is.EqualTo(_mainForm.Process.Id));
+ }
+ }
+
+ [Test]
+ public void Find_falls_back_to_the_name()
+ {
+ Assert.That(_mainForm.Find("Click me").AutomationId, Is.EqualTo("btnClick"));
+ }
+
+ [Test]
+ public void The_form_starts_in_its_initial_state()
+ {
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(_mainForm.ResultLabel.Name, Is.EqualTo("Ready"));
+ Assert.That(_mainForm.InputBox.ValuePattern.Value, Is.Empty);
+ Assert.That(_mainForm.ToggleCheckBox.TogglePattern.State, Is.EqualTo(ToggleState.Off));
+ Assert.That(_mainForm.VolatileButton.Name, Is.EqualTo("Volatile 1"));
+ Assert.That(ItemNames(), Is.EqualTo(["Item A", "Item B", "Item C"]));
+ Assert.That(_mainForm.ItemsList.FindAll(e => e.ControlType == ControlType.ListItem, TreeScope.Children).Select(i => i.Name), Is.EqualTo(["Alpha", "Beta", "Gamma"]));
+ }
+ }
+
+ [Test]
+ public void Selecting_a_list_item_selects_it()
+ {
+ var beta = _mainForm.ItemsList.Find(e => e.Name == "Beta");
+
+ beta.Select();
+
+ Assert.That(beta.SelectionItemPattern.IsSelected, Is.True);
+ }
+
+ [Test]
+ public void Recreate_increments_the_volatile_button_generation()
+ {
+ _mainForm.RecreateButton.Click();
+ Eventually(() => _mainForm.VolatileButton.Name == "Volatile 2");
+
+ _mainForm.RecreateButton.Click();
+ Eventually(() => _mainForm.VolatileButton.Name == "Volatile 3");
+ }
+
+ [Test]
+ public void Reverse_twice_restores_the_item_order()
+ {
+ _mainForm.ReverseButton.Click();
+ Eventually(() => ItemNames().SequenceEqual(["Item C", "Item B", "Item A"]));
+
+ _mainForm.ReverseButton.Click();
+ Eventually(() => ItemNames().SequenceEqual(["Item A", "Item B", "Item C"]));
+ }
+
+ [Test]
+ public void Clicking_the_button_updates_the_label()
+ {
+ _mainForm.Button.Click();
+
+ Eventually(() => _mainForm.ResultLabel.Name == "Clicked");
+ }
+
+ [Test]
+ public void SetValue_round_trips()
+ {
+ var input = _mainForm.InputBox;
+
+ input.SetValue("round trip");
+
+ Assert.That(input.ValuePattern.Value, Is.EqualTo("round trip"));
+ }
+
+ [Test]
+ public void Toggle_changes_the_checkbox()
+ {
+ var toggle = _mainForm.ToggleCheckBox;
+
+ toggle.Toggle();
+
+ Assert.That(toggle.TogglePattern.State, Is.EqualTo(ToggleState.On));
+ }
+
+ [TearDown]
+ public void TearDown() => _mainForm.Dispose();
+
+ private IEnumerable ItemNames() => _mainForm.ItemsPanel.FindAll(e => e.ControlType == ControlType.Button, TreeScope.Children).Select(i => i.Name);
+}
diff --git a/examples/WinUia.Examples.Winforms.Tests/Tests/PatternTests.cs b/examples/WinUia.Examples.Winforms.Tests/Tests/PatternTests.cs
new file mode 100644
index 0000000..cf226b9
--- /dev/null
+++ b/examples/WinUia.Examples.Winforms.Tests/Tests/PatternTests.cs
@@ -0,0 +1,180 @@
+using WinUia.Core.Exceptions;
+using WinUia.Core.Patterns;
+using WinUia.Core;
+using WinUia.Examples.Winforms.Tests.Application;
+using WinUia.Examples.Winforms.Tests.Extensions;
+using WinUia.Input;
+
+namespace WinUia.Examples.Winforms.Tests.Tests;
+
+/// Control patterns, searches, tree navigation and physical input, exercised on the example app's controls.
+
+[UiTest]
+public sealed class PatternTests
+{
+ private MainForm _mainForm = null!;
+
+ [SetUp]
+ public void SetUp() => _mainForm = App.LaunchApplication();
+
+ [TearDown]
+ public void TearDown() => _mainForm.Dispose();
+
+ [System.Runtime.InteropServices.DllImport("user32.dll", EntryPoint = "GetWindowLongPtrW")]
+ private static extern nint GetWindowLongPtr(nint hwnd, int index);
+
+ private static bool IsTopmostWindow(nint hwnd) => (GetWindowLongPtr(hwnd, -20 /* GWL_EXSTYLE */) & 0x8 /* WS_EX_TOPMOST */) != 0;
+
+ [Test]
+ public void Unsupported_pattern_throws()
+ {
+ var label = _mainForm.ResultLabel;
+
+ Assert.That(label.InvokePattern.IsSupported, Is.False);
+ Assert.Throws(() => label.InvokePattern.Invoke());
+ }
+
+ [Test]
+ public void Window_pattern_reports_window_state()
+ {
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(_mainForm.Window.WindowPattern.IsSupported, Is.True);
+ Assert.That(_mainForm.Window.WindowPattern.VisualState, Is.EqualTo(WindowVisualState.Normal));
+ }
+ }
+
+ [Test]
+ public void GetText_reads_the_value_of_an_edit()
+ {
+ var input = _mainForm.InputBox;
+ input.SetValue("some text");
+
+ Assert.That(input.GetText(), Is.EqualTo("some text"));
+ }
+
+ [Test]
+ public void Clickable_point_is_inside_the_bounding_rectangle()
+ {
+ var button = _mainForm.Button;
+
+ var point = button.GetClickablePoint();
+ var rect = button.BoundingRectangle;
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(point.X, Is.InRange(rect.Left, rect.Right));
+ Assert.That(point.Y, Is.InRange(rect.Top, rect.Bottom));
+ }
+ }
+
+ [Test]
+ public void Composite_conditions_search_correctly()
+ {
+ var both = _mainForm.Window.Find(e => e.AutomationId == "btnClick" && e.ControlType == ControlType.Button);
+ var either = _mainForm.Window.Find(e => e.AutomationId == "nope" || e.AutomationId == "chkToggle");
+ var notButton = _mainForm.Window.FindAll(e => !(e.ControlType == ControlType.Button), TreeScope.Children);
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(both.AutomationId, Is.EqualTo("btnClick"));
+ Assert.That(either.AutomationId, Is.EqualTo("chkToggle"));
+ Assert.That(notButton, Has.None.Matches(e => e.ControlType == ControlType.Button));
+ }
+ }
+
+ [Test]
+ public void Predicate_searches_find_the_expected_elements()
+ {
+ var button = _mainForm.Window.Find(e => e.AutomationId == "btnClick" && e.ControlType == ControlType.Button);
+ var items = _mainForm.ItemsList.FindAll(e => e.ControlType == ControlType.ListItem && e.Name != "Beta", TreeScope.Children);
+ var missing = _mainForm.Window.TryFind(e => e.Name == "nope", timeout: TimeSpan.Zero);
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(button.AutomationId, Is.EqualTo("btnClick"));
+ Assert.That(items.Select(i => i.Name), Is.EqualTo(["Alpha", "Gamma"]));
+ Assert.That(missing, Is.Null);
+ }
+ }
+
+ [Test]
+ public void FromPoint_and_GetFocusedElement_return_the_expected_elements()
+ {
+ var input = _mainForm.InputBox;
+ input.Focus();
+
+ Eventually(() => _mainForm.Context.GetFocusedElement().AutomationId == "txtInput", "SetFocus moves keyboard focus");
+ Assert.That(_mainForm.Context.FromPoint(_mainForm.Button.GetClickablePoint()).AutomationId, Is.EqualTo("btnClick"));
+ }
+
+ [Test]
+ public void Parent_and_Ancestors_walk_up_to_the_root()
+ {
+ var button = _mainForm.Button;
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(button.Parent!.IsSameAs(_mainForm.Window), Is.True);
+ Assert.That(button.Ancestors().Last().IsSameAs(_mainForm.Context.GetRootElement()), Is.True);
+ }
+ }
+
+ [Test]
+ public void AddToSelection_and_RemoveFromSelection_change_a_multi_select_list()
+ {
+ var list = _mainForm.ItemsList;
+ var alpha = list.Find(e => e.Name == "Alpha");
+ var gamma = list.Find(e => e.Name == "Gamma");
+
+ alpha.Select();
+ gamma.SelectionItemPattern.AddToSelection();
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(alpha.SelectionItemPattern.IsSelected, Is.True);
+ Assert.That(gamma.SelectionItemPattern.IsSelected, Is.True);
+ }
+
+ alpha.SelectionItemPattern.RemoveFromSelection();
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(alpha.SelectionItemPattern.IsSelected, Is.False);
+ Assert.That(gamma.SelectionItemPattern.IsSelected, Is.True);
+ }
+ }
+
+ [Test]
+ public void Window_pattern_members_work()
+ {
+ var window = _mainForm.Window.WindowPattern;
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(window.CanMaximize, Is.True);
+ Assert.That(window.CanMinimize, Is.True);
+ Assert.That(window.IsModal, Is.False);
+ // Compare with Win32 rather than assume the fixture's TopMost: it is the vtable slot under test here.
+ Assert.That(window.IsTopmost, Is.EqualTo(IsTopmostWindow(_mainForm.Window.NativeWindowHandle)));
+ }
+
+ // The WinForms provider returns E_NOTIMPL for WaitForInputIdle.
+ Assert.Throws(() => window.WaitForInputIdle(TimeSpan.FromSeconds(5)));
+
+ window.SetVisualState(WindowVisualState.Maximized);
+ Eventually(() => window.VisualState == WindowVisualState.Maximized, "the window maximizes");
+ window.SetVisualState(WindowVisualState.Normal);
+ Eventually(() => window.VisualState == WindowVisualState.Normal, "the window restores");
+ }
+
+ [Test]
+ public void Physical_click_and_typing_reach_the_app()
+ {
+ var input = _mainForm.InputBox;
+
+ input.PhysicalClick();
+ Eventually(() => input.HasKeyboardFocus, "a physical click focuses the text box");
+
+ Win32InputSimulator.SendText("typed");
+ Eventually(() => input.ValuePattern.Value == "typed", "SendText types into the focused text box");
+ }
+}
diff --git a/examples/WinUia.Examples.Winforms.Tests/Tests/SelfHealingTests.cs b/examples/WinUia.Examples.Winforms.Tests/Tests/SelfHealingTests.cs
new file mode 100644
index 0000000..a155707
--- /dev/null
+++ b/examples/WinUia.Examples.Winforms.Tests/Tests/SelfHealingTests.cs
@@ -0,0 +1,104 @@
+using WinUia.Core.Exceptions;
+using WinUia.Core;
+using WinUia.Examples.Winforms.Tests.Application;
+using WinUia.Examples.Winforms.Tests.Extensions;
+
+namespace WinUia.Examples.Winforms.Tests.Tests;
+
+///
+/// Elements re-find themselves when their control is re-created: FindFirst results through their search, FindAll
+/// results by identity. The example app's "Recreate" button replaces btnVolatile; "Reverse" re-creates the pnlItems buttons
+/// in reverse order.
+///
+[UiTest]
+public sealed class SelfHealingTests
+{
+ private MainForm _mainForm = null!;
+
+ [SetUp]
+ public void SetUp() => _mainForm = App.LaunchApplication();
+
+ [TearDown]
+ public void TearDown() => _mainForm.Dispose();
+
+ private IReadOnlyList Items() => _mainForm.ItemsPanel.FindAll(e => e.ControlType == ControlType.Button, TreeScope.Children);
+
+ private IEnumerable ItemNames() => Items().Select(i => i.Name);
+
+ private void Recreate()
+ {
+ _mainForm.RecreateButton.Click();
+ Eventually(() => _mainForm.VolatileButton.Name == "Volatile 2", "Recreate replaces the volatile button");
+ }
+
+ [Test]
+ public void A_found_element_re_resolves_after_its_control_is_recreated()
+ {
+ var volatileButton = _mainForm.VolatileButton;
+ Assert.That(volatileButton.Name, Is.EqualTo("Volatile 1"));
+
+ Recreate();
+
+ Assert.That(volatileButton.Name, Is.EqualTo("Volatile 2"));
+ }
+
+ [Test]
+ public void FindAll_elements_re_resolve_to_the_same_control_when_siblings_move()
+ {
+ var items = Items();
+ var namesBefore = items.Select(i => i.Name).ToArray();
+ Assert.That(namesBefore, Is.EquivalentTo(["Item A", "Item B", "Item C"]));
+
+ _mainForm.ReverseButton.Click();
+ Eventually(() => ItemNames().SequenceEqual(namesBefore.Reverse()));
+
+ Assert.That(items.Select(i => i.Name), Is.EqualTo(namesBefore));
+ }
+
+ [Test]
+ public void A_FindAll_element_never_read_before_going_stale_reports_stale_instead_of_guessing()
+ {
+ var items = Items();
+
+ _mainForm.ReverseButton.Click();
+ Eventually(() => ItemNames().First() == "Item C");
+
+ Assert.Throws(() => _ = items[0].Name);
+ }
+
+ [Test]
+ public void Without_stale_retries_a_recreated_control_reports_stale()
+ {
+ var volatileButton = _mainForm.VolatileButton;
+ Assert.That(volatileButton.Name, Is.EqualTo("Volatile 1"));
+ Recreate();
+
+ _mainForm.Context.StaleRetryCount = 0;
+
+ Assert.Throws(() => _ = volatileButton.Name);
+ }
+
+ [Test]
+ public void An_element_without_a_locator_reports_stale_after_its_control_is_recreated()
+ {
+ var byHandle = _mainForm.Context.FromHandle(_mainForm.VolatileButton.NativeWindowHandle);
+ Assert.That(byHandle.Name, Is.EqualTo("Volatile 1"));
+
+ Recreate();
+
+ Assert.Throws(() => _ = byHandle.Name);
+ }
+
+ [Test]
+ public void A_found_child_re_resolves_after_it_and_its_siblings_are_recreated()
+ {
+ var panel = _mainForm.ItemsPanel;
+ var itemA = panel.Find(e => e.Name == "Item A", TreeScope.Children);
+ Assert.That(itemA.Name, Is.EqualTo("Item A"));
+
+ _mainForm.ReverseButton.Click();
+ Eventually(() => ItemNames().First() == "Item C");
+
+ Assert.That(itemA.Name, Is.EqualTo("Item A"));
+ }
+}
diff --git a/examples/WinUia.Examples.Winforms.Tests/Tests/TabTests.cs b/examples/WinUia.Examples.Winforms.Tests/Tests/TabTests.cs
new file mode 100644
index 0000000..1d8ccb9
--- /dev/null
+++ b/examples/WinUia.Examples.Winforms.Tests/Tests/TabTests.cs
@@ -0,0 +1,73 @@
+using WinUia.Examples.Winforms.Tests.Application;
+using WinUia.Examples.Winforms.Tests.Extensions;
+
+namespace WinUia.Examples.Winforms.Tests.Tests;
+
+[UiTest]
+public sealed class TabTests
+{
+ private MainForm _mainForm = null!;
+
+ [SetUp]
+ public void SetUp() => _mainForm = App.LaunchApplication();
+
+ [Test]
+ public void Tab_1_is_selected_at_start()
+ {
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(_mainForm.TabHeader("Tab 1").SelectionItemPattern.IsSelected, Is.True);
+ Assert.That(_mainForm.TabHeader("Tab 2").SelectionItemPattern.IsSelected, Is.False);
+ }
+ }
+
+ [Test]
+ public void SelectTab1_shows_the_tab_1_content()
+ {
+ var tab1 = _mainForm.SelectTab1();
+
+ Assert.That(tab1.Content.Name, Is.EqualTo("tab1 content"));
+ }
+
+ [Test]
+ public void SelectTab2_selects_the_tab_and_shows_the_tab_2_content()
+ {
+ var tab2 = _mainForm.SelectTab2();
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(_mainForm.TabHeader("Tab 2").SelectionItemPattern.IsSelected, Is.True);
+ Assert.That(tab2.Content.Name, Is.EqualTo("tab2 content"));
+ }
+ }
+
+ [Test]
+ public void Switching_tabs_hides_the_other_tabs_content()
+ {
+ _mainForm.SelectTab2();
+ var tab1 = _mainForm.SelectTab1();
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(tab1.Content.Name, Is.EqualTo("tab1 content"));
+ Assert.That(_mainForm.Window.TryFindByAutomationId("lblTab2", TimeSpan.FromMilliseconds(200)), Is.Null);
+ }
+ }
+
+ [Test]
+ public void The_main_form_controls_keep_working_while_another_tab_is_selected()
+ {
+ var tab2 = _mainForm.SelectTab2();
+
+ _mainForm.Button.Click();
+
+ using (Assert.EnterMultipleScope())
+ {
+ Eventually(() => _mainForm.ResultLabel.Name == "Clicked");
+ Assert.That(tab2.Content.Name, Is.EqualTo("tab2 content"));
+ }
+ }
+
+ [TearDown]
+ public void TearDown() => _mainForm.Dispose();
+}
diff --git a/examples/WinUia.Examples.Winforms.Tests/WinUia.Examples.Winforms.Tests.csproj b/examples/WinUia.Examples.Winforms.Tests/WinUia.Examples.Winforms.Tests.csproj
new file mode 100644
index 0000000..82a6f80
--- /dev/null
+++ b/examples/WinUia.Examples.Winforms.Tests/WinUia.Examples.Winforms.Tests.csproj
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ <_Parameter1>ApplicationPath
+ <_Parameter2>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\WinUia.Examples.Winforms\bin\$(Configuration)\$(TargetFramework)\WinUia.Examples.Winforms.exe'))
+
+
+
+
+
+
+ <_Parameter1>System.Threading.ApartmentState.MTA
+ <_Parameter1_IsLiteral>true
+
+
+
+
diff --git a/examples/WinUia.Examples.Winforms/DialogForm.Designer.cs b/examples/WinUia.Examples.Winforms/DialogForm.Designer.cs
new file mode 100644
index 0000000..ab5742f
--- /dev/null
+++ b/examples/WinUia.Examples.Winforms/DialogForm.Designer.cs
@@ -0,0 +1,102 @@
+namespace WinUia.Examples.Winforms
+{
+ partial class DialogForm
+ {
+ ///
+ /// Required designer variable.
+ ///
+ private System.ComponentModel.IContainer components = null;
+
+ ///
+ /// Clean up any resources being used.
+ ///
+ /// true if managed resources should be disposed; otherwise, false.
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing && (components != null))
+ {
+ components.Dispose();
+ }
+ base.Dispose(disposing);
+ }
+
+ #region Windows Form Designer generated code
+
+ ///
+ /// Required method for Designer support - do not modify
+ /// the contents of this method with the code editor.
+ ///
+ private void InitializeComponent()
+ {
+ lblDialogMessage = new Label();
+ txtDialogInput = new TextBox();
+ btnDialogOk = new Button();
+ btnDialogCancel = new Button();
+ SuspendLayout();
+ //
+ // lblDialogMessage
+ //
+ lblDialogMessage.AutoSize = true;
+ lblDialogMessage.Location = new Point(12, 12);
+ lblDialogMessage.Name = "lblDialogMessage";
+ lblDialogMessage.Size = new Size(84, 15);
+ lblDialogMessage.TabIndex = 0;
+ lblDialogMessage.Text = "Enter a value:";
+ //
+ // txtDialogInput
+ //
+ txtDialogInput.Location = new Point(12, 35);
+ txtDialogInput.Name = "txtDialogInput";
+ txtDialogInput.Size = new Size(236, 23);
+ txtDialogInput.TabIndex = 1;
+ //
+ // btnDialogOk
+ //
+ btnDialogOk.DialogResult = DialogResult.OK;
+ btnDialogOk.Location = new Point(92, 70);
+ btnDialogOk.Name = "btnDialogOk";
+ btnDialogOk.Size = new Size(75, 23);
+ btnDialogOk.TabIndex = 2;
+ btnDialogOk.Text = "OK";
+ btnDialogOk.UseVisualStyleBackColor = true;
+ //
+ // btnDialogCancel
+ //
+ btnDialogCancel.DialogResult = DialogResult.Cancel;
+ btnDialogCancel.Location = new Point(173, 70);
+ btnDialogCancel.Name = "btnDialogCancel";
+ btnDialogCancel.Size = new Size(75, 23);
+ btnDialogCancel.TabIndex = 3;
+ btnDialogCancel.Text = "Cancel";
+ btnDialogCancel.UseVisualStyleBackColor = true;
+ //
+ // DialogForm
+ //
+ AcceptButton = btnDialogOk;
+ AutoScaleDimensions = new SizeF(7F, 15F);
+ AutoScaleMode = AutoScaleMode.Font;
+ CancelButton = btnDialogCancel;
+ ClientSize = new Size(260, 105);
+ Controls.Add(lblDialogMessage);
+ Controls.Add(txtDialogInput);
+ Controls.Add(btnDialogOk);
+ Controls.Add(btnDialogCancel);
+ FormBorderStyle = FormBorderStyle.FixedDialog;
+ MaximizeBox = false;
+ MinimizeBox = false;
+ Name = "DialogForm";
+ ShowInTaskbar = false;
+ StartPosition = FormStartPosition.CenterParent;
+ Text = "WinUia Dialog";
+ ResumeLayout(false);
+ PerformLayout();
+ }
+
+ #endregion
+
+ private Label lblDialogMessage;
+ private TextBox txtDialogInput;
+ private Button btnDialogOk;
+ private Button btnDialogCancel;
+ }
+}
diff --git a/examples/WinUia.Examples.Winforms/DialogForm.cs b/examples/WinUia.Examples.Winforms/DialogForm.cs
new file mode 100644
index 0000000..899ec29
--- /dev/null
+++ b/examples/WinUia.Examples.Winforms/DialogForm.cs
@@ -0,0 +1,13 @@
+namespace WinUia.Examples.Winforms;
+
+/// Modal dialog opened by btnOpenDialog: asks for a value, closed with OK or Cancel.
+public partial class DialogForm : Form
+{
+ public DialogForm()
+ {
+ InitializeComponent();
+ }
+
+ /// The value typed into the dialog.
+ public string InputText => txtDialogInput.Text;
+}
diff --git a/examples/WinUia.Examples.Winforms/MainForm.Designer.cs b/examples/WinUia.Examples.Winforms/MainForm.Designer.cs
new file mode 100644
index 0000000..06c9588
--- /dev/null
+++ b/examples/WinUia.Examples.Winforms/MainForm.Designer.cs
@@ -0,0 +1,279 @@
+namespace WinUia.Examples.Winforms
+{
+ partial class MainForm
+ {
+ ///
+ /// Required designer variable.
+ ///
+ private System.ComponentModel.IContainer components = null;
+
+ ///
+ /// Clean up any resources being used.
+ ///
+ /// true if managed resources should be disposed; otherwise, false.
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing && (components != null))
+ {
+ components.Dispose();
+ }
+ base.Dispose(disposing);
+ }
+
+ #region Windows Form Designer generated code
+
+ ///
+ /// Required method for Designer support - do not modify
+ /// the contents of this method with the code editor.
+ ///
+ private void InitializeComponent()
+ {
+ lblResult = new Label();
+ btnClick = new Button();
+ txtInput = new TextBox();
+ chkToggle = new CheckBox();
+ lstItems = new ListBox();
+ btnRecreate = new Button();
+ btnVolatile = new Button();
+ pnlItems = new FlowLayoutPanel();
+ btnItemA = new Button();
+ btnItemB = new Button();
+ btnItemC = new Button();
+ btnReverse = new Button();
+ btnOpenDialog = new Button();
+ tabMain = new TabControl();
+ tabPage1 = new TabPage();
+ lblTab1 = new Label();
+ tabPage2 = new TabPage();
+ lblTab2 = new Label();
+ pnlItems.SuspendLayout();
+ tabMain.SuspendLayout();
+ tabPage1.SuspendLayout();
+ tabPage2.SuspendLayout();
+ SuspendLayout();
+ //
+ // lblResult
+ //
+ lblResult.AutoSize = true;
+ lblResult.Location = new Point(12, 12);
+ lblResult.Name = "lblResult";
+ lblResult.Size = new Size(39, 15);
+ lblResult.TabIndex = 0;
+ lblResult.Text = "Ready";
+ //
+ // btnClick
+ //
+ btnClick.Location = new Point(12, 40);
+ btnClick.Name = "btnClick";
+ btnClick.Size = new Size(120, 23);
+ btnClick.TabIndex = 1;
+ btnClick.Text = "Click me";
+ btnClick.UseVisualStyleBackColor = true;
+ btnClick.Click += btnClick_Click;
+ //
+ // txtInput
+ //
+ txtInput.Location = new Point(12, 75);
+ txtInput.Name = "txtInput";
+ txtInput.Size = new Size(200, 23);
+ txtInput.TabIndex = 2;
+ //
+ // chkToggle
+ //
+ chkToggle.AutoSize = true;
+ chkToggle.Location = new Point(12, 105);
+ chkToggle.Name = "chkToggle";
+ chkToggle.Size = new Size(80, 19);
+ chkToggle.TabIndex = 3;
+ chkToggle.Text = "Toggle me";
+ chkToggle.UseVisualStyleBackColor = true;
+ //
+ // lstItems
+ //
+ lstItems.FormattingEnabled = true;
+ lstItems.ItemHeight = 15;
+ lstItems.Items.AddRange(new object[] { "Alpha", "Beta", "Gamma" });
+ lstItems.Location = new Point(230, 40);
+ lstItems.Name = "lstItems";
+ lstItems.SelectionMode = SelectionMode.MultiExtended;
+ lstItems.Size = new Size(110, 79);
+ lstItems.TabIndex = 4;
+ //
+ // btnRecreate
+ //
+ btnRecreate.Location = new Point(12, 140);
+ btnRecreate.Name = "btnRecreate";
+ btnRecreate.Size = new Size(120, 23);
+ btnRecreate.TabIndex = 5;
+ btnRecreate.Text = "Recreate";
+ btnRecreate.UseVisualStyleBackColor = true;
+ btnRecreate.Click += btnRecreate_Click;
+ //
+ // btnVolatile
+ //
+ btnVolatile.Location = new Point(140, 140);
+ btnVolatile.Name = "btnVolatile";
+ btnVolatile.Size = new Size(120, 23);
+ btnVolatile.TabIndex = 6;
+ btnVolatile.Text = "Volatile 1";
+ btnVolatile.UseVisualStyleBackColor = true;
+ //
+ // pnlItems
+ //
+ pnlItems.Controls.Add(btnItemA);
+ pnlItems.Controls.Add(btnItemB);
+ pnlItems.Controls.Add(btnItemC);
+ pnlItems.Location = new Point(12, 175);
+ pnlItems.Name = "pnlItems";
+ pnlItems.Size = new Size(330, 35);
+ pnlItems.TabIndex = 7;
+ //
+ // btnItemA
+ //
+ btnItemA.Location = new Point(3, 3);
+ btnItemA.Name = "btnItemA";
+ btnItemA.Size = new Size(100, 23);
+ btnItemA.TabIndex = 0;
+ btnItemA.Text = "Item A";
+ btnItemA.UseVisualStyleBackColor = true;
+ //
+ // btnItemB
+ //
+ btnItemB.Location = new Point(109, 3);
+ btnItemB.Name = "btnItemB";
+ btnItemB.Size = new Size(100, 23);
+ btnItemB.TabIndex = 1;
+ btnItemB.Text = "Item B";
+ btnItemB.UseVisualStyleBackColor = true;
+ //
+ // btnItemC
+ //
+ btnItemC.Location = new Point(215, 3);
+ btnItemC.Name = "btnItemC";
+ btnItemC.Size = new Size(100, 23);
+ btnItemC.TabIndex = 2;
+ btnItemC.Text = "Item C";
+ btnItemC.UseVisualStyleBackColor = true;
+ //
+ // btnReverse
+ //
+ btnReverse.Location = new Point(12, 215);
+ btnReverse.Name = "btnReverse";
+ btnReverse.Size = new Size(120, 23);
+ btnReverse.TabIndex = 8;
+ btnReverse.Text = "Reverse";
+ btnReverse.UseVisualStyleBackColor = true;
+ btnReverse.Click += btnReverse_Click;
+ //
+ // btnOpenDialog
+ //
+ btnOpenDialog.Location = new Point(140, 215);
+ btnOpenDialog.Name = "btnOpenDialog";
+ btnOpenDialog.Size = new Size(120, 23);
+ btnOpenDialog.TabIndex = 9;
+ btnOpenDialog.Text = "Open dialog";
+ btnOpenDialog.UseVisualStyleBackColor = true;
+ btnOpenDialog.Click += btnOpenDialog_Click;
+ //
+ // tabMain
+ //
+ tabMain.Controls.Add(tabPage1);
+ tabMain.Controls.Add(tabPage2);
+ tabMain.Location = new Point(12, 250);
+ tabMain.Name = "tabMain";
+ tabMain.SelectedIndex = 0;
+ tabMain.Size = new Size(336, 80);
+ tabMain.TabIndex = 10;
+ //
+ // tabPage1
+ //
+ tabPage1.Controls.Add(lblTab1);
+ tabPage1.Location = new Point(4, 24);
+ tabPage1.Name = "tabPage1";
+ tabPage1.Padding = new Padding(3);
+ tabPage1.Size = new Size(328, 52);
+ tabPage1.TabIndex = 0;
+ tabPage1.Text = "Tab 1";
+ tabPage1.UseVisualStyleBackColor = true;
+ //
+ // lblTab1
+ //
+ lblTab1.AutoSize = true;
+ lblTab1.Location = new Point(6, 6);
+ lblTab1.Name = "lblTab1";
+ lblTab1.Size = new Size(72, 15);
+ lblTab1.TabIndex = 0;
+ lblTab1.Text = "tab1 content";
+ //
+ // tabPage2
+ //
+ tabPage2.Controls.Add(lblTab2);
+ tabPage2.Location = new Point(4, 24);
+ tabPage2.Name = "tabPage2";
+ tabPage2.Padding = new Padding(3);
+ tabPage2.Size = new Size(328, 52);
+ tabPage2.TabIndex = 1;
+ tabPage2.Text = "Tab 2";
+ tabPage2.UseVisualStyleBackColor = true;
+ //
+ // lblTab2
+ //
+ lblTab2.AutoSize = true;
+ lblTab2.Location = new Point(6, 6);
+ lblTab2.Name = "lblTab2";
+ lblTab2.Size = new Size(72, 15);
+ lblTab2.TabIndex = 0;
+ lblTab2.Text = "tab2 content";
+ //
+ // MainForm
+ //
+ AutoScaleDimensions = new SizeF(7F, 15F);
+ AutoScaleMode = AutoScaleMode.Font;
+ ClientSize = new Size(360, 340);
+ Controls.Add(lblResult);
+ Controls.Add(btnClick);
+ Controls.Add(txtInput);
+ Controls.Add(chkToggle);
+ Controls.Add(lstItems);
+ Controls.Add(btnRecreate);
+ Controls.Add(btnVolatile);
+ Controls.Add(pnlItems);
+ Controls.Add(btnReverse);
+ Controls.Add(btnOpenDialog);
+ Controls.Add(tabMain);
+ Name = "MainForm";
+ StartPosition = FormStartPosition.CenterScreen;
+ Text = "WinUia Test App";
+ TopMost = true;
+ pnlItems.ResumeLayout(false);
+ tabMain.ResumeLayout(false);
+ tabPage1.ResumeLayout(false);
+ tabPage1.PerformLayout();
+ tabPage2.ResumeLayout(false);
+ tabPage2.PerformLayout();
+ ResumeLayout(false);
+ PerformLayout();
+ }
+
+ #endregion
+
+ private Label lblResult;
+ private Button btnClick;
+ private TextBox txtInput;
+ private CheckBox chkToggle;
+ private ListBox lstItems;
+ private Button btnRecreate;
+ private Button btnVolatile;
+ private FlowLayoutPanel pnlItems;
+ private Button btnItemA;
+ private Button btnItemB;
+ private Button btnItemC;
+ private Button btnReverse;
+ private Button btnOpenDialog;
+ private TabControl tabMain;
+ private TabPage tabPage1;
+ private Label lblTab1;
+ private TabPage tabPage2;
+ private Label lblTab2;
+ }
+}
diff --git a/examples/WinUia.Examples.Winforms/MainForm.cs b/examples/WinUia.Examples.Winforms/MainForm.cs
new file mode 100644
index 0000000..18ce67d
--- /dev/null
+++ b/examples/WinUia.Examples.Winforms/MainForm.cs
@@ -0,0 +1,68 @@
+namespace WinUia.Examples.Winforms;
+
+///
+/// Main window of the WinForms example app, automated by examples/WinUia.Examples.Winforms.Tests. The controls are laid out in the designer; WinForms exposes each
+/// control's as its UI Automation AutomationId, so those names are the ids the tests
+/// search for. The window is TopMost because the physical-input tests click screen coordinates.
+///
+public partial class MainForm : Form
+{
+ private int _generation = 1;
+
+ public MainForm()
+ {
+ InitializeComponent();
+ }
+
+ private void btnClick_Click(object? sender, EventArgs e) => lblResult.Text = "Clicked";
+
+ /// Opens the modal DialogForm and reports how it was closed in lblResult.
+ private void btnOpenDialog_Click(object? sender, EventArgs e)
+ {
+ using var dialog = new DialogForm();
+ var result = dialog.ShowDialog(this);
+ lblResult.Text = result == DialogResult.OK ? $"Dialog: OK ({dialog.InputText})" : $"Dialog: {result}";
+ }
+
+ ///
+ /// Destroys btnVolatile and creates a new one with the same AutomationId, which makes elements found earlier
+ /// stale (their window handle is gone) — the WinUI 3 re-render case.
+ ///
+ private void btnRecreate_Click(object? sender, EventArgs e)
+ {
+ var old = btnVolatile;
+ btnVolatile = Recreate(old, $"Volatile {++_generation}");
+ Controls.Remove(old);
+ old.Dispose();
+ Controls.Add(btnVolatile);
+ }
+
+ ///
+ /// Recreates the buttons in pnlItems in reverse order: every button gets a new window handle and most change
+ /// position, so index-based re-resolution would pick the wrong one.
+ ///
+ private void btnReverse_Click(object? sender, EventArgs e)
+ {
+ var old = pnlItems.Controls.Cast