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