From e1d1ecc2c95afb30e6cf06a60cd977e6bd577e1c Mon Sep 17 00:00:00 2001 From: Jelle Buning Date: Thu, 1 Oct 2026 12:43:13 +0200 Subject: [PATCH 1/4] Add typed app launch options --- .github/README.md | 33 +++-- WinUia.slnx | 12 +- .../Application/{MainForm => }/DialogForm.cs | 0 .../Application/{MainForm => }/Tab1.cs | 0 .../Application/{MainForm => }/Tab2.cs | 0 .../{MainForm.cs => WinFormsApp.cs} | 17 ++- .../Extensions/AppExtensions.cs | 19 --- .../Tests/DialogTests.cs | 21 ++- .../Tests/MainFormTests.cs | 47 +++--- .../Tests/PatternTests.cs | 94 ++++++++---- .../Tests/SelfHealingTests.cs | 29 ++-- .../Tests/TabTests.cs | 29 ++-- .../DialogForm.Designer.cs | 2 + .../MainForm.Designer.cs | 8 ++ examples/WinUia.Examples.Winforms/MainForm.cs | 4 +- src/Directory.Build.props | 4 +- src/WinUia.Core/AutomationContext.cs | 14 ++ src/WinUia.Core/ComExceptionMapper.cs | 2 +- src/WinUia.Core/Element.Interaction.cs | 36 ++++- src/WinUia.Core/ElementPredicate.cs | 2 +- .../UiaElementNotEnabledException.cs | 2 + .../UiaNoClickablePointException.cs | 2 + .../UiaPatternNotSupportedException.cs | 2 + .../Exceptions/UiaStaleElementException.cs | 2 + .../Exceptions/UiaTimeoutException.cs | 2 + .../{Exceptions => Interop}/HResults.cs | 2 +- .../Interop/IUIAutomationElement.cs | 22 +-- .../Interop/IUIAutomationTextRange.cs | 2 +- .../Interop/IUIAutomationValuePattern.cs | 2 +- src/WinUia.Input/Interop/NativeMethods.cs | 4 + src/WinUia.Input/Interop/POINT.cs | 10 ++ src/WinUia.Input/Win32InputSimulator.cs | 101 +++++++++++-- src/WinUia.NUnit/UiTestAttribute.cs | 8 +- src/WinUia/App.cs | 128 +++++++++++------ src/WinUia/AppLaunchOptions.cs | 43 ++++++ src/WinUia/Launchers/AppLauncher.cs | 43 +++++- src/WinUia/Launchers/PackagedAppActivator.cs | 1 + tests/Directory.Build.props | 13 +- .../AutomationContextTests.cs | 32 ++++- .../ElementPredicateConditionTests.cs | 15 ++ .../InteropVtableTests.cs | 23 +-- .../WinUia.Core.IntegrationTests.csproj | 8 ++ .../WinUia.Core.UiTests.csproj | 12 ++ .../WindowPropertyTests.cs | 38 +++++ .../ElementPredicateTests.cs | 11 +- .../WinUia.Core.UnitTests.csproj | 6 +- .../PhysicalDpiTests.cs | 6 +- .../WinUia.Input.IntegrationTests.csproj | 8 ++ tests/WinUia.Input.UiTests/CursorTests.cs | 58 ++++++++ .../WinUia.Input.UiTests.csproj | 8 ++ .../WinUia.Input.UnitTests.csproj | 1 + .../AppLauncherTests.cs | 58 ++++++++ tests/WinUia.IntegrationTests/AppTests.cs | 10 ++ .../WinUia.IntegrationTests.csproj | 8 ++ .../DesktopLockTests.cs | 2 +- .../WinUia.NUnit.IntegrationTests.csproj | 5 + .../UiTestAttributeTests.cs | 7 +- .../WinUia.NUnit.UiTests.csproj | 5 + .../AppLauncherTests.cs | 27 +--- tests/WinUia.UiTests/AppTests.cs | 135 ++++++++++++++++++ .../DerivedApp.cs | 2 +- tests/WinUia.UiTests/LaunchableApp.cs | 13 ++ tests/WinUia.UiTests/WinUia.UiTests.csproj | 10 ++ .../WinUia.UnitTests/AppLaunchOptionsTests.cs | 28 ++++ tests/WinUia.UnitTests/AppTests.cs | 106 ++------------ tests/WinUia.UnitTests/UnconnectedApp.cs | 4 + .../WinUia.UnitTests/WinUia.UnitTests.csproj | 3 +- 67 files changed, 1048 insertions(+), 363 deletions(-) rename examples/WinUia.Examples.Winforms.Tests/Application/{MainForm => }/DialogForm.cs (100%) rename examples/WinUia.Examples.Winforms.Tests/Application/{MainForm => }/Tab1.cs (100%) rename examples/WinUia.Examples.Winforms.Tests/Application/{MainForm => }/Tab2.cs (100%) rename examples/WinUia.Examples.Winforms.Tests/Application/{MainForm.cs => WinFormsApp.cs} (63%) delete mode 100644 examples/WinUia.Examples.Winforms.Tests/Extensions/AppExtensions.cs rename src/WinUia.Core/{Exceptions => Interop}/HResults.cs (95%) create mode 100644 src/WinUia.Input/Interop/POINT.cs create mode 100644 src/WinUia/AppLaunchOptions.cs rename tests/{WinUia.Core.UnitTests => WinUia.Core.IntegrationTests}/AutomationContextTests.cs (71%) create mode 100644 tests/WinUia.Core.IntegrationTests/ElementPredicateConditionTests.cs rename tests/{WinUia.Core.UnitTests => WinUia.Core.IntegrationTests}/InteropVtableTests.cs (81%) create mode 100644 tests/WinUia.Core.IntegrationTests/WinUia.Core.IntegrationTests.csproj create mode 100644 tests/WinUia.Core.UiTests/WinUia.Core.UiTests.csproj create mode 100644 tests/WinUia.Core.UiTests/WindowPropertyTests.cs rename tests/{WinUia.Input.UnitTests => WinUia.Input.IntegrationTests}/PhysicalDpiTests.cs (86%) create mode 100644 tests/WinUia.Input.IntegrationTests/WinUia.Input.IntegrationTests.csproj create mode 100644 tests/WinUia.Input.UiTests/CursorTests.cs create mode 100644 tests/WinUia.Input.UiTests/WinUia.Input.UiTests.csproj create mode 100644 tests/WinUia.IntegrationTests/AppLauncherTests.cs create mode 100644 tests/WinUia.IntegrationTests/AppTests.cs create mode 100644 tests/WinUia.IntegrationTests/WinUia.IntegrationTests.csproj rename tests/{WinUia.NUnit.UnitTests => WinUia.NUnit.IntegrationTests}/DesktopLockTests.cs (95%) create mode 100644 tests/WinUia.NUnit.IntegrationTests/WinUia.NUnit.IntegrationTests.csproj rename tests/{WinUia.NUnit.UnitTests => WinUia.NUnit.UiTests}/UiTestAttributeTests.cs (81%) create mode 100644 tests/WinUia.NUnit.UiTests/WinUia.NUnit.UiTests.csproj rename tests/{WinUia.UnitTests => WinUia.UiTests}/AppLauncherTests.cs (75%) create mode 100644 tests/WinUia.UiTests/AppTests.cs rename tests/{WinUia.UnitTests => WinUia.UiTests}/DerivedApp.cs (88%) create mode 100644 tests/WinUia.UiTests/LaunchableApp.cs create mode 100644 tests/WinUia.UiTests/WinUia.UiTests.csproj create mode 100644 tests/WinUia.UnitTests/AppLaunchOptionsTests.cs create mode 100644 tests/WinUia.UnitTests/UnconnectedApp.cs diff --git a/.github/README.md b/.github/README.md index 01f4e53..8ff8dbb 100644 --- a/.github/README.md +++ b/.github/README.md @@ -113,18 +113,27 @@ dialog.Find(e => e.ControlType == ControlType.Button && e.Name == "Yes").Click() 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`): +A page object for the whole application derives from `App` and lists its controls; the generic factories +(`App.Launch`, `App.LaunchPackaged`, `App.Attach`) create it already connected. A page object can also say +which executable it is and how it starts by default, so tests launch it without repeating either. `AppLaunchOptions` +sets the arguments, working directory, environment, pointer and main-window timeout; options passed to a factory +override the page object's defaults one by one. 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 { + protected override string ExecutablePath => @"C:\path\to\MyApp.exe"; + protected override AppLaunchOptions DefaultOptions => new() { ShowPointer = true }; + 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 +using var page = App.Launch(); // its executable and defaults +using var quiet = App.Launch(new AppLaunchOptions { ShowPointer = false }); // one default overridden +using var other = App.Launch(@"D:\builds\MyApp.exe"); // another executable +// Disposing a page closes its app. ``` ### Project structure @@ -142,15 +151,23 @@ 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. +* Internals are shared only through `InternalsVisibleTo`, and only with the module's own test projects. * 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 +Each module has up to three test projects under `tests/`, by what the tests need: + +| Project | Tests | Needs | +|---|---|---| +| `.UnitTests` | The module's code on its own | Nothing | +| `.IntegrationTests` | Against the real UI Automation client, Win32 and other OS objects, without showing a window | Windows | +| `.UiTests` | Launch an app or move the real mouse and keyboard | An interactive, unlocked desktop | + +Every test in a `.UiTests` project gets `[UiTest]` from the build (category `UI`), so +`dotnet test --filter "Category!=UI"` runs the unit and integration tests on any Windows machine. UI tests 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/WinUia.Examples.Winforms.Tests`. 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 diff --git a/WinUia.slnx b/WinUia.slnx index 86ccf17..320f723 100644 --- a/WinUia.slnx +++ b/WinUia.slnx @@ -6,12 +6,20 @@ - - + + + + + + + + + + diff --git a/examples/WinUia.Examples.Winforms.Tests/Application/MainForm/DialogForm.cs b/examples/WinUia.Examples.Winforms.Tests/Application/DialogForm.cs similarity index 100% rename from examples/WinUia.Examples.Winforms.Tests/Application/MainForm/DialogForm.cs rename to examples/WinUia.Examples.Winforms.Tests/Application/DialogForm.cs diff --git a/examples/WinUia.Examples.Winforms.Tests/Application/MainForm/Tab1.cs b/examples/WinUia.Examples.Winforms.Tests/Application/Tab1.cs similarity index 100% rename from examples/WinUia.Examples.Winforms.Tests/Application/MainForm/Tab1.cs rename to examples/WinUia.Examples.Winforms.Tests/Application/Tab1.cs diff --git a/examples/WinUia.Examples.Winforms.Tests/Application/MainForm/Tab2.cs b/examples/WinUia.Examples.Winforms.Tests/Application/Tab2.cs similarity index 100% rename from examples/WinUia.Examples.Winforms.Tests/Application/MainForm/Tab2.cs rename to examples/WinUia.Examples.Winforms.Tests/Application/Tab2.cs diff --git a/examples/WinUia.Examples.Winforms.Tests/Application/MainForm.cs b/examples/WinUia.Examples.Winforms.Tests/Application/WinFormsApp.cs similarity index 63% rename from examples/WinUia.Examples.Winforms.Tests/Application/MainForm.cs rename to examples/WinUia.Examples.Winforms.Tests/Application/WinFormsApp.cs index 64d53fd..390533a 100644 --- a/examples/WinUia.Examples.Winforms.Tests/Application/MainForm.cs +++ b/examples/WinUia.Examples.Winforms.Tests/Application/WinFormsApp.cs @@ -1,14 +1,23 @@ +using System.Reflection; 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. +/// Page object for the example WinForms app: the itself, with the controls of its main window. +/// It knows its own executable and defaults, so tests launch it with App.Launch<WinFormsApp>(), or +/// App.Launch<WinFormsApp>(new AppLaunchOptions { ShowPointer = false }) where nobody watches. +/// The tab pages and the dialog are page objects of their own, built on the elements they cover. /// -public sealed class MainForm : App +public sealed class WinFormsApp : App { + /// + protected override string ExecutablePath => typeof(WinFormsApp).Assembly.GetCustomAttributes() + .Single(a => a.Key == "ApplicationPath").Value!; + + /// The cursor moves to every element a test interacts with, so you can follow a run. + protected override AppLaunchOptions DefaultOptions => new() { ShowPointer = true }; + public Element Window => MainWindow; public Element Button => Window.FindByAutomationId("btnClick"); public Element ResultLabel => Window.FindByAutomationId("lblResult"); diff --git a/examples/WinUia.Examples.Winforms.Tests/Extensions/AppExtensions.cs b/examples/WinUia.Examples.Winforms.Tests/Extensions/AppExtensions.cs deleted file mode 100644 index 328af40..0000000 --- a/examples/WinUia.Examples.Winforms.Tests/Extensions/AppExtensions.cs +++ /dev/null @@ -1,19 +0,0 @@ -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 index 663d184..a75cbfa 100644 --- a/examples/WinUia.Examples.Winforms.Tests/Tests/DialogTests.cs +++ b/examples/WinUia.Examples.Winforms.Tests/Tests/DialogTests.cs @@ -1,23 +1,22 @@ 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!; + private WinFormsApp _app = null!; [SetUp] - public void SetUp() => _mainForm = App.LaunchApplication(); + public void SetUp() => _app = App.Launch(); [Test] public void Opening_the_dialog_shows_a_modal_window() { var stopwatch = Stopwatch.StartNew(); - var dialog = _mainForm.OpenDialog(); + var dialog = _app.OpenDialog(); using (Assert.EnterMultipleScope()) { @@ -31,34 +30,34 @@ public void Opening_the_dialog_shows_a_modal_window() [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); + Assert.That(_app.TryFindWindow("No such window", TimeSpan.FromMilliseconds(100)), Is.Null); } [Test] public void Confirming_the_dialog_reports_the_entered_value() { - var dialog = _mainForm.OpenDialog(); + var dialog = _app.OpenDialog(); dialog.InputBox.SetValue("hello"); dialog.OkButton.Click(); - Eventually(() => _mainForm.ResultLabel.Name == "Dialog: OK (hello)"); + Eventually(() => _app.ResultLabel.Name == "Dialog: OK (hello)"); } [Test] public void Cancelling_the_dialog_reports_cancel() { - var dialog = _mainForm.OpenDialog(); + var dialog = _app.OpenDialog(); dialog.CancelButton.Click(); - Eventually(() => _mainForm.ResultLabel.Name == "Dialog: Cancel"); + Eventually(() => _app.ResultLabel.Name == "Dialog: Cancel"); } [Test] public void Closing_the_dialog_removes_it() { - var dialog = _mainForm.OpenDialog(); + var dialog = _app.OpenDialog(); dialog.CancelButton.Click(); @@ -77,5 +76,5 @@ public void Closing_the_dialog_removes_it() } [TearDown] - public void TearDown() => _mainForm.Dispose(); + public void TearDown() => _app.Dispose(); } diff --git a/examples/WinUia.Examples.Winforms.Tests/Tests/MainFormTests.cs b/examples/WinUia.Examples.Winforms.Tests/Tests/MainFormTests.cs index d51bd8d..488270e 100644 --- a/examples/WinUia.Examples.Winforms.Tests/Tests/MainFormTests.cs +++ b/examples/WinUia.Examples.Winforms.Tests/Tests/MainFormTests.cs @@ -1,32 +1,31 @@ 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!; + private WinFormsApp _app = null!; [SetUp] - public void SetUp() => _mainForm = App.LaunchApplication(); + public void SetUp() => _app = App.Launch(); [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)); + Assert.That(_app.Window.Name, Is.EqualTo("WinUia Test App")); + Assert.That(_app.Window.ProcessId, Is.EqualTo(_app.Process.Id)); } } [Test] public void Find_falls_back_to_the_name() { - Assert.That(_mainForm.Find("Click me").AutomationId, Is.EqualTo("btnClick")); + Assert.That(_app.Find("Click me").AutomationId, Is.EqualTo("btnClick")); } [Test] @@ -34,19 +33,19 @@ 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(_app.ResultLabel.Name, Is.EqualTo("Ready")); + Assert.That(_app.InputBox.ValuePattern.Value, Is.Empty); + Assert.That(_app.ToggleCheckBox.TogglePattern.State, Is.EqualTo(ToggleState.Off)); + Assert.That(_app.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"])); + Assert.That(_app.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"); + var beta = _app.ItemsList.Find(e => e.Name == "Beta"); beta.Select(); @@ -56,35 +55,35 @@ public void Selecting_a_list_item_selects_it() [Test] public void Recreate_increments_the_volatile_button_generation() { - _mainForm.RecreateButton.Click(); - Eventually(() => _mainForm.VolatileButton.Name == "Volatile 2"); + _app.RecreateButton.Click(); + Eventually(() => _app.VolatileButton.Name == "Volatile 2"); - _mainForm.RecreateButton.Click(); - Eventually(() => _mainForm.VolatileButton.Name == "Volatile 3"); + _app.RecreateButton.Click(); + Eventually(() => _app.VolatileButton.Name == "Volatile 3"); } [Test] public void Reverse_twice_restores_the_item_order() { - _mainForm.ReverseButton.Click(); + _app.ReverseButton.Click(); Eventually(() => ItemNames().SequenceEqual(["Item C", "Item B", "Item A"])); - _mainForm.ReverseButton.Click(); + _app.ReverseButton.Click(); Eventually(() => ItemNames().SequenceEqual(["Item A", "Item B", "Item C"])); } [Test] public void Clicking_the_button_updates_the_label() { - _mainForm.Button.Click(); + _app.Button.Click(); - Eventually(() => _mainForm.ResultLabel.Name == "Clicked"); + Eventually(() => _app.ResultLabel.Name == "Clicked"); } [Test] public void SetValue_round_trips() { - var input = _mainForm.InputBox; + var input = _app.InputBox; input.SetValue("round trip"); @@ -94,7 +93,7 @@ public void SetValue_round_trips() [Test] public void Toggle_changes_the_checkbox() { - var toggle = _mainForm.ToggleCheckBox; + var toggle = _app.ToggleCheckBox; toggle.Toggle(); @@ -102,7 +101,7 @@ public void Toggle_changes_the_checkbox() } [TearDown] - public void TearDown() => _mainForm.Dispose(); + public void TearDown() => _app.Dispose(); - private IEnumerable ItemNames() => _mainForm.ItemsPanel.FindAll(e => e.ControlType == ControlType.Button, TreeScope.Children).Select(i => i.Name); + private IEnumerable ItemNames() => _app.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 index cf226b9..7a8d30e 100644 --- a/examples/WinUia.Examples.Winforms.Tests/Tests/PatternTests.cs +++ b/examples/WinUia.Examples.Winforms.Tests/Tests/PatternTests.cs @@ -2,7 +2,6 @@ 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; @@ -12,13 +11,13 @@ namespace WinUia.Examples.Winforms.Tests.Tests; [UiTest] public sealed class PatternTests { - private MainForm _mainForm = null!; + private WinFormsApp _app = null!; [SetUp] - public void SetUp() => _mainForm = App.LaunchApplication(); + public void SetUp() => _app = App.Launch(); [TearDown] - public void TearDown() => _mainForm.Dispose(); + public void TearDown() => _app.Dispose(); [System.Runtime.InteropServices.DllImport("user32.dll", EntryPoint = "GetWindowLongPtrW")] private static extern nint GetWindowLongPtr(nint hwnd, int index); @@ -28,7 +27,7 @@ public sealed class PatternTests [Test] public void Unsupported_pattern_throws() { - var label = _mainForm.ResultLabel; + var label = _app.ResultLabel; Assert.That(label.InvokePattern.IsSupported, Is.False); Assert.Throws(() => label.InvokePattern.Invoke()); @@ -39,15 +38,15 @@ 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)); + Assert.That(_app.Window.WindowPattern.IsSupported, Is.True); + Assert.That(_app.Window.WindowPattern.VisualState, Is.EqualTo(WindowVisualState.Normal)); } } [Test] public void GetText_reads_the_value_of_an_edit() { - var input = _mainForm.InputBox; + var input = _app.InputBox; input.SetValue("some text"); Assert.That(input.GetText(), Is.EqualTo("some text")); @@ -56,7 +55,7 @@ public void GetText_reads_the_value_of_an_edit() [Test] public void Clickable_point_is_inside_the_bounding_rectangle() { - var button = _mainForm.Button; + var button = _app.Button; var point = button.GetClickablePoint(); var rect = button.BoundingRectangle; @@ -71,9 +70,9 @@ public void Clickable_point_is_inside_the_bounding_rectangle() [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); + var both = _app.Window.Find(e => e.AutomationId == "btnClick" && e.ControlType == ControlType.Button); + var either = _app.Window.Find(e => e.AutomationId == "nope" || e.AutomationId == "chkToggle"); + var notButton = _app.Window.FindAll(e => !(e.ControlType == ControlType.Button), TreeScope.Children); using (Assert.EnterMultipleScope()) { @@ -86,9 +85,9 @@ public void Composite_conditions_search_correctly() [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); + var button = _app.Window.Find(e => e.AutomationId == "btnClick" && e.ControlType == ControlType.Button); + var items = _app.ItemsList.FindAll(e => e.ControlType == ControlType.ListItem && e.Name != "Beta", TreeScope.Children); + var missing = _app.Window.TryFind(e => e.Name == "nope", timeout: TimeSpan.Zero); using (Assert.EnterMultipleScope()) { @@ -101,29 +100,29 @@ public void Predicate_searches_find_the_expected_elements() [Test] public void FromPoint_and_GetFocusedElement_return_the_expected_elements() { - var input = _mainForm.InputBox; + var input = _app.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")); + Eventually(() => _app.Context.GetFocusedElement().AutomationId == "txtInput", "SetFocus moves keyboard focus"); + Assert.That(_app.Context.FromPoint(_app.Button.GetClickablePoint()).AutomationId, Is.EqualTo("btnClick")); } [Test] public void Parent_and_Ancestors_walk_up_to_the_root() { - var button = _mainForm.Button; + var button = _app.Button; using (Assert.EnterMultipleScope()) { - Assert.That(button.Parent!.IsSameAs(_mainForm.Window), Is.True); - Assert.That(button.Ancestors().Last().IsSameAs(_mainForm.Context.GetRootElement()), Is.True); + Assert.That(button.Parent!.IsSameAs(_app.Window), Is.True); + Assert.That(button.Ancestors().Last().IsSameAs(_app.Context.GetRootElement()), Is.True); } } [Test] public void AddToSelection_and_RemoveFromSelection_change_a_multi_select_list() { - var list = _mainForm.ItemsList; + var list = _app.ItemsList; var alpha = list.Find(e => e.Name == "Alpha"); var gamma = list.Find(e => e.Name == "Gamma"); @@ -146,7 +145,7 @@ public void AddToSelection_and_RemoveFromSelection_change_a_multi_select_list() [Test] public void Window_pattern_members_work() { - var window = _mainForm.Window.WindowPattern; + var window = _app.Window.WindowPattern; using (Assert.EnterMultipleScope()) { @@ -154,7 +153,7 @@ public void Window_pattern_members_work() 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))); + Assert.That(window.IsTopmost, Is.EqualTo(IsTopmostWindow(_app.Window.NativeWindowHandle))); } // The WinForms provider returns E_NOTIMPL for WaitForInputIdle. @@ -166,10 +165,55 @@ public void Window_pattern_members_work() Eventually(() => window.VisualState == WindowVisualState.Normal, "the window restores"); } + [Test] + public void With_ShowPointer_the_cursor_moves_to_each_element_before_the_interaction() + { + _app.Context.ShowPointer = true; + Win32InputSimulator.MoveTo(5, 5); + + _app.Button.Click(); + var afterClick = Win32InputSimulator.GetCursorPosition(); + _app.InputBox.SetValue("pointed at"); + var afterSetValue = Win32InputSimulator.GetCursorPosition(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(Contains(_app.Button.BoundingRectangle, afterClick), Is.True, "the cursor is on the button after Click"); + Assert.That(Contains(_app.InputBox.BoundingRectangle, afterSetValue), Is.True, "the cursor is on the text box after SetValue"); + Assert.That(_app.InputBox.ValuePattern.Value, Is.EqualTo("pointed at")); + } + + Eventually(() => _app.ResultLabel.Name == "Clicked", "the click still goes through"); + } + + [Test] + public void Without_ShowPointer_interactions_do_not_move_the_cursor_to_the_elements() + { + _app.Context.ShowPointer = false; // WinFormsApp turns it on by default. + Win32InputSimulator.MoveTo(5, 5); // Away from the app's window. + + _app.Button.Click(); + var afterClick = Win32InputSimulator.GetCursorPosition(); + _app.InputBox.SetValue("not pointed at"); + var afterSetValue = Win32InputSimulator.GetCursorPosition(); + + // Not "the cursor stays at (5, 5)": someone using the mouse during the run would break that. What ShowPointer + // controls is whether WinUia moves the cursor onto the elements, and nobody else does that by accident. + using (Assert.EnterMultipleScope()) + { + Assert.That(Contains(_app.Button.BoundingRectangle, afterClick), Is.False, "Click does not point at the button"); + Assert.That(Contains(_app.InputBox.BoundingRectangle, afterSetValue), Is.False, "SetValue does not point at the text box"); + Assert.That(_app.InputBox.ValuePattern.Value, Is.EqualTo("not pointed at")); + } + } + + private static bool Contains(ScreenRect rect, (int X, int Y) point) => + point.X >= rect.Left && point.X <= rect.Right && point.Y >= rect.Top && point.Y <= rect.Bottom; + [Test] public void Physical_click_and_typing_reach_the_app() { - var input = _mainForm.InputBox; + var input = _app.InputBox; input.PhysicalClick(); Eventually(() => input.HasKeyboardFocus, "a physical click focuses the text box"); diff --git a/examples/WinUia.Examples.Winforms.Tests/Tests/SelfHealingTests.cs b/examples/WinUia.Examples.Winforms.Tests/Tests/SelfHealingTests.cs index a155707..203f2d2 100644 --- a/examples/WinUia.Examples.Winforms.Tests/Tests/SelfHealingTests.cs +++ b/examples/WinUia.Examples.Winforms.Tests/Tests/SelfHealingTests.cs @@ -1,7 +1,6 @@ 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; @@ -13,28 +12,28 @@ namespace WinUia.Examples.Winforms.Tests.Tests; [UiTest] public sealed class SelfHealingTests { - private MainForm _mainForm = null!; + private WinFormsApp _app = null!; [SetUp] - public void SetUp() => _mainForm = App.LaunchApplication(); + public void SetUp() => _app = App.Launch(); [TearDown] - public void TearDown() => _mainForm.Dispose(); + public void TearDown() => _app.Dispose(); - private IReadOnlyList Items() => _mainForm.ItemsPanel.FindAll(e => e.ControlType == ControlType.Button, TreeScope.Children); + private IReadOnlyList Items() => _app.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"); + _app.RecreateButton.Click(); + Eventually(() => _app.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; + var volatileButton = _app.VolatileButton; Assert.That(volatileButton.Name, Is.EqualTo("Volatile 1")); Recreate(); @@ -49,7 +48,7 @@ public void FindAll_elements_re_resolve_to_the_same_control_when_siblings_move() var namesBefore = items.Select(i => i.Name).ToArray(); Assert.That(namesBefore, Is.EquivalentTo(["Item A", "Item B", "Item C"])); - _mainForm.ReverseButton.Click(); + _app.ReverseButton.Click(); Eventually(() => ItemNames().SequenceEqual(namesBefore.Reverse())); Assert.That(items.Select(i => i.Name), Is.EqualTo(namesBefore)); @@ -60,7 +59,7 @@ public void A_FindAll_element_never_read_before_going_stale_reports_stale_instea { var items = Items(); - _mainForm.ReverseButton.Click(); + _app.ReverseButton.Click(); Eventually(() => ItemNames().First() == "Item C"); Assert.Throws(() => _ = items[0].Name); @@ -69,11 +68,11 @@ public void A_FindAll_element_never_read_before_going_stale_reports_stale_instea [Test] public void Without_stale_retries_a_recreated_control_reports_stale() { - var volatileButton = _mainForm.VolatileButton; + var volatileButton = _app.VolatileButton; Assert.That(volatileButton.Name, Is.EqualTo("Volatile 1")); Recreate(); - _mainForm.Context.StaleRetryCount = 0; + _app.Context.StaleRetryCount = 0; Assert.Throws(() => _ = volatileButton.Name); } @@ -81,7 +80,7 @@ public void Without_stale_retries_a_recreated_control_reports_stale() [Test] public void An_element_without_a_locator_reports_stale_after_its_control_is_recreated() { - var byHandle = _mainForm.Context.FromHandle(_mainForm.VolatileButton.NativeWindowHandle); + var byHandle = _app.Context.FromHandle(_app.VolatileButton.NativeWindowHandle); Assert.That(byHandle.Name, Is.EqualTo("Volatile 1")); Recreate(); @@ -92,11 +91,11 @@ public void An_element_without_a_locator_reports_stale_after_its_control_is_recr [Test] public void A_found_child_re_resolves_after_it_and_its_siblings_are_recreated() { - var panel = _mainForm.ItemsPanel; + var panel = _app.ItemsPanel; var itemA = panel.Find(e => e.Name == "Item A", TreeScope.Children); Assert.That(itemA.Name, Is.EqualTo("Item A")); - _mainForm.ReverseButton.Click(); + _app.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 index 1d8ccb9..7cad440 100644 --- a/examples/WinUia.Examples.Winforms.Tests/Tests/TabTests.cs +++ b/examples/WinUia.Examples.Winforms.Tests/Tests/TabTests.cs @@ -1,30 +1,29 @@ 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!; + private WinFormsApp _app = null!; [SetUp] - public void SetUp() => _mainForm = App.LaunchApplication(); + public void SetUp() => _app = App.Launch(); [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); + Assert.That(_app.TabHeader("Tab 1").SelectionItemPattern.IsSelected, Is.True); + Assert.That(_app.TabHeader("Tab 2").SelectionItemPattern.IsSelected, Is.False); } } [Test] public void SelectTab1_shows_the_tab_1_content() { - var tab1 = _mainForm.SelectTab1(); + var tab1 = _app.SelectTab1(); Assert.That(tab1.Content.Name, Is.EqualTo("tab1 content")); } @@ -32,11 +31,11 @@ public void SelectTab1_shows_the_tab_1_content() [Test] public void SelectTab2_selects_the_tab_and_shows_the_tab_2_content() { - var tab2 = _mainForm.SelectTab2(); + var tab2 = _app.SelectTab2(); using (Assert.EnterMultipleScope()) { - Assert.That(_mainForm.TabHeader("Tab 2").SelectionItemPattern.IsSelected, Is.True); + Assert.That(_app.TabHeader("Tab 2").SelectionItemPattern.IsSelected, Is.True); Assert.That(tab2.Content.Name, Is.EqualTo("tab2 content")); } } @@ -44,30 +43,30 @@ public void SelectTab2_selects_the_tab_and_shows_the_tab_2_content() [Test] public void Switching_tabs_hides_the_other_tabs_content() { - _mainForm.SelectTab2(); - var tab1 = _mainForm.SelectTab1(); + _app.SelectTab2(); + var tab1 = _app.SelectTab1(); using (Assert.EnterMultipleScope()) { Assert.That(tab1.Content.Name, Is.EqualTo("tab1 content")); - Assert.That(_mainForm.Window.TryFindByAutomationId("lblTab2", TimeSpan.FromMilliseconds(200)), Is.Null); + Assert.That(_app.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(); + var tab2 = _app.SelectTab2(); - _mainForm.Button.Click(); + _app.Button.Click(); using (Assert.EnterMultipleScope()) { - Eventually(() => _mainForm.ResultLabel.Name == "Clicked"); + Eventually(() => _app.ResultLabel.Name == "Clicked"); Assert.That(tab2.Content.Name, Is.EqualTo("tab2 content")); } } [TearDown] - public void TearDown() => _mainForm.Dispose(); + public void TearDown() => _app.Dispose(); } diff --git a/examples/WinUia.Examples.Winforms/DialogForm.Designer.cs b/examples/WinUia.Examples.Winforms/DialogForm.Designer.cs index ab5742f..2c35ca5 100644 --- a/examples/WinUia.Examples.Winforms/DialogForm.Designer.cs +++ b/examples/WinUia.Examples.Winforms/DialogForm.Designer.cs @@ -53,6 +53,7 @@ private void InitializeComponent() // btnDialogOk // btnDialogOk.DialogResult = DialogResult.OK; + btnDialogOk.Cursor = Cursors.Hand; btnDialogOk.Location = new Point(92, 70); btnDialogOk.Name = "btnDialogOk"; btnDialogOk.Size = new Size(75, 23); @@ -63,6 +64,7 @@ private void InitializeComponent() // btnDialogCancel // btnDialogCancel.DialogResult = DialogResult.Cancel; + btnDialogCancel.Cursor = Cursors.Hand; btnDialogCancel.Location = new Point(173, 70); btnDialogCancel.Name = "btnDialogCancel"; btnDialogCancel.Size = new Size(75, 23); diff --git a/examples/WinUia.Examples.Winforms/MainForm.Designer.cs b/examples/WinUia.Examples.Winforms/MainForm.Designer.cs index 06c9588..eb56eea 100644 --- a/examples/WinUia.Examples.Winforms/MainForm.Designer.cs +++ b/examples/WinUia.Examples.Winforms/MainForm.Designer.cs @@ -63,6 +63,7 @@ private void InitializeComponent() // // btnClick // + btnClick.Cursor = Cursors.Hand; btnClick.Location = new Point(12, 40); btnClick.Name = "btnClick"; btnClick.Size = new Size(120, 23); @@ -101,6 +102,7 @@ private void InitializeComponent() // // btnRecreate // + btnRecreate.Cursor = Cursors.Hand; btnRecreate.Location = new Point(12, 140); btnRecreate.Name = "btnRecreate"; btnRecreate.Size = new Size(120, 23); @@ -111,6 +113,7 @@ private void InitializeComponent() // // btnVolatile // + btnVolatile.Cursor = Cursors.Hand; btnVolatile.Location = new Point(140, 140); btnVolatile.Name = "btnVolatile"; btnVolatile.Size = new Size(120, 23); @@ -130,6 +133,7 @@ private void InitializeComponent() // // btnItemA // + btnItemA.Cursor = Cursors.Hand; btnItemA.Location = new Point(3, 3); btnItemA.Name = "btnItemA"; btnItemA.Size = new Size(100, 23); @@ -139,6 +143,7 @@ private void InitializeComponent() // // btnItemB // + btnItemB.Cursor = Cursors.Hand; btnItemB.Location = new Point(109, 3); btnItemB.Name = "btnItemB"; btnItemB.Size = new Size(100, 23); @@ -148,6 +153,7 @@ private void InitializeComponent() // // btnItemC // + btnItemC.Cursor = Cursors.Hand; btnItemC.Location = new Point(215, 3); btnItemC.Name = "btnItemC"; btnItemC.Size = new Size(100, 23); @@ -157,6 +163,7 @@ private void InitializeComponent() // // btnReverse // + btnReverse.Cursor = Cursors.Hand; btnReverse.Location = new Point(12, 215); btnReverse.Name = "btnReverse"; btnReverse.Size = new Size(120, 23); @@ -167,6 +174,7 @@ private void InitializeComponent() // // btnOpenDialog // + btnOpenDialog.Cursor = Cursors.Hand; btnOpenDialog.Location = new Point(140, 215); btnOpenDialog.Name = "btnOpenDialog"; btnOpenDialog.Size = new Size(120, 23); diff --git a/examples/WinUia.Examples.Winforms/MainForm.cs b/examples/WinUia.Examples.Winforms/MainForm.cs index 18ce67d..7c1d6cc 100644 --- a/examples/WinUia.Examples.Winforms/MainForm.cs +++ b/examples/WinUia.Examples.Winforms/MainForm.cs @@ -14,6 +14,7 @@ public MainForm() InitializeComponent(); } + // ReSharper disable once LocalizableElement (the tests read this text) private void btnClick_Click(object? sender, EventArgs e) => lblResult.Text = "Clicked"; /// Opens the modal DialogForm and reports how it was closed in lblResult. @@ -44,7 +45,7 @@ private void btnRecreate_Click(object? sender, EventArgs e) private void btnReverse_Click(object? sender, EventArgs e) { var old = pnlItems.Controls.Cast