diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..9022c3d --- /dev/null +++ b/.editorconfig @@ -0,0 +1,6 @@ +root = true + +# Win32 and COM interop declarations keep the Windows SDK names (INPUT, BM_CLICK, UIA_E_ELEMENTNOTAVAILABLE, ...), +# so each one can be looked up in the SDK headers and documentation as it is written there. +[**/Interop/*.cs] +resharper_inconsistent_naming_highlighting = none diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md deleted file mode 100644 index 7285119..0000000 --- a/.github/CODE_OF_CONDUCT.md +++ /dev/null @@ -1,127 +0,0 @@ -# Contributor Covenant Code of Conduct - -## Our Pledge - -We as members, contributors, and leaders pledge to make participation in our -community a harassment-free experience for everyone, regardless of age, body -size, visible or invisible disability, ethnicity, sex characteristics, gender -identity and expression, level of experience, education, socio-economic status, -nationality, personal appearance, race, religion, or sexual identity -and orientation. - -We pledge to act and interact in ways that contribute to an open, welcoming, -diverse, inclusive, and healthy community. - -## Our Standards - -Examples of behavior that contributes to a positive environment for our -community include: - -* Demonstrating empathy and kindness toward other people -* Being respectful of differing opinions, viewpoints, and experiences -* Giving and gracefully accepting constructive feedback -* Accepting responsibility and apologizing to those affected by our mistakes, - and learning from the experience -* Focusing on what is best not just for us as individuals, but for the - overall community - -Examples of unacceptable behavior include: - -* The use of sexualized language or imagery, and sexual attention or - advances of any kind -* Trolling, insulting or derogatory comments, and personal or political attacks -* Public or private harassment -* Publishing others' private information, such as a physical or email - address, without their explicit permission -* Other conduct which could reasonably be considered inappropriate in a - professional setting - -## Enforcement Responsibilities - -Community leaders are responsible for clarifying and enforcing our standards of -acceptable behavior and will take appropriate and fair corrective action in -response to any behavior that they deem inappropriate, threatening, offensive, -or harmful. - -Community leaders have the right and responsibility to remove, edit, or reject -comments, commits, code, wiki edits, issues, and other contributions that are -not aligned to this Code of Conduct, and will communicate reasons for moderation -decisions when appropriate. - -## Scope - -This Code of Conduct applies within all community spaces, and also applies when -an individual is officially representing the community in public spaces. -Examples of representing our community include using an official e-mail address, -posting via an official social media account, or acting as an appointed -representative at an online or offline event. - -## Enforcement - -Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported to the community leaders responsible for enforcement at. -All complaints will be reviewed and investigated promptly and fairly. - -All community leaders are obligated to respect the privacy and security of the -reporter of any incident. - -## Enforcement Guidelines - -Community leaders will follow these Community Impact Guidelines in determining -the consequences for any action they deem in violation of this Code of Conduct: - -### 1. Correction - -**Community Impact**: Use of inappropriate language or other behavior deemed -unprofessional or unwelcome in the community. - -**Consequence**: A private, written warning from community leaders, providing -clarity around the nature of the violation and an explanation of why the -behavior was inappropriate. A public apology may be requested. - -### 2. Warning - -**Community Impact**: A violation through a single incident or series -of actions. - -**Consequence**: A warning with consequences for continued behavior. No -interaction with the people involved, including unsolicited interaction with -those enforcing the Code of Conduct, for a specified period of time. This -includes avoiding interactions in community spaces as well as external channels -like social media. Violating these terms may lead to a temporary or -permanent ban. - -### 3. Temporary Ban - -**Community Impact**: A serious violation of community standards, including -sustained inappropriate behavior. - -**Consequence**: A temporary ban from any sort of interaction or public -communication with the community for a specified period of time. No public or -private interaction with the people involved, including unsolicited interaction -with those enforcing the Code of Conduct, is allowed during this period. -Violating these terms may lead to a permanent ban. - -### 4. Permanent Ban - -**Community Impact**: Demonstrating a pattern of violation of community -standards, including sustained inappropriate behavior, harassment of an -individual, or aggression toward or disparagement of classes of individuals. - -**Consequence**: A permanent ban from any sort of public interaction within -the community. - -## Attribution - -This Code of Conduct is adapted from the [Contributor Covenant][homepage], -version 2.0, available at -https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. - -Community Impact Guidelines were inspired by [Mozilla's code of conduct -enforcement ladder](https://github.com/mozilla/diversity). - -[homepage]: https://www.contributor-covenant.org - -For answers to common questions about this code of conduct, see the FAQ at -https://www.contributor-covenant.org/faq. Translations are available at -https://www.contributor-covenant.org/translations. diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml deleted file mode 100644 index 9a5b47a..0000000 --- a/.github/FUNDING.yml +++ /dev/null @@ -1,11 +0,0 @@ -github: JelleBuning -patreon: -open_collective: -ko_fi: -tidelift: -community_bridge: -liberapay: -issuehunt: -otechie: -lfx_crowdfunding: -custom: diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index dd84ea7..0000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -name: Bug report -about: Create a report to help us improve -title: '' -labels: '' -assignees: '' - ---- - -**Describe the bug** -A clear and concise description of what the bug is. - -**To Reproduce** -Steps to reproduce the behavior: -1. Go to '...' -2. Click on '....' -3. Scroll down to '....' -4. See error - -**Expected behavior** -A clear and concise description of what you expected to happen. - -**Screenshots** -If applicable, add screenshots to help explain your problem. - -**Desktop (please complete the following information):** - - OS: [e.g. iOS] - - Browser [e.g. chrome, safari] - - Version [e.g. 22] - -**Smartphone (please complete the following information):** - - Device: [e.g. iPhone6] - - OS: [e.g. iOS8.1] - - Browser [e.g. stock browser, safari] - - Version [e.g. 22] - -**Additional context** -Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index bbcbbe7..0000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -name: Feature request -about: Suggest an idea for this project -title: '' -labels: '' -assignees: '' - ---- - -**Is your feature request related to a problem? Please describe.** -A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] - -**Describe the solution you'd like** -A clear and concise description of what you want to happen. - -**Describe alternatives you've considered** -A clear and concise description of any alternative solutions or features you've considered. - -**Additional context** -Add any other context or screenshots about the feature request here. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md deleted file mode 100644 index 7c3c9fa..0000000 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ /dev/null @@ -1,6 +0,0 @@ -## Description - -## Types of changes -- [ ] Bug fix (non-breaking change which fixes an issue) -- [ ] New feature (non-breaking change which adds functionality) -- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) \ No newline at end of file diff --git a/.github/README.md b/.github/README.md index 01f4e53..f808c70 100644 --- a/.github/README.md +++ b/.github/README.md @@ -11,9 +11,9 @@

A .NET library for automating Windows applications through Microsoft UI Automation.
- Report Bug + Report Bug · - Request Feature + Request Feature

@@ -72,7 +72,7 @@ Setting up WinUia on your local machine is straightforward. Make sure the [.NET 1. **Clone the repository:** ```bash - git clone https://github.com/JelleBuning/win-uia.git + git clone https://github.com/BuningSoftware/win-uia.git cd win-uia ``` @@ -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 @@ -135,22 +144,30 @@ WinUia is a modular monolith: one solution, one project per module, and one test |---|---|---|---| | `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.Input` | Platform | `IInputSimulator` and `Win32InputSimulator` (`SendInput`; each `AutomationContext` has one as `Input`, replaceable in tests), `PhysicalDpi` | — | | `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. +* Each module's `Interop/` folder is private to that module. Other modules use what it offers (`IInputSimulator`, `PhysicalDpi`), 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 @@ -189,13 +206,13 @@ Distributed under the GNU Affero General Public License v3.0 License. See `LICEN -[contributors-shield]: https://img.shields.io/github/contributors/JelleBuning/win-uia.svg?style=for-the-badge -[contributors-url]: https://github.com/JelleBuning/win-uia/graphs/contributors -[forks-shield]: https://img.shields.io/github/forks/JelleBuning/win-uia.svg?style=for-the-badge -[forks-url]: https://github.com/JelleBuning/win-uia/network/members -[stars-shield]: https://img.shields.io/github/stars/JelleBuning/win-uia.svg?style=for-the-badge -[stars-url]: https://github.com/JelleBuning/win-uia/stargazers -[issues-shield]: https://img.shields.io/github/issues/JelleBuning/win-uia.svg?style=for-the-badge -[issues-url]: https://github.com/JelleBuning/win-uia/issues -[license-shield]: https://img.shields.io/github/license/JelleBuning/win-uia.svg?style=for-the-badge -[license-url]: https://github.com/JelleBuning/win-uia/blob/main/LICENSE +[contributors-shield]: https://img.shields.io/github/contributors/BuningSoftware/win-uia.svg?style=for-the-badge +[contributors-url]: https://github.com/BuningSoftware/win-uia/graphs/contributors +[forks-shield]: https://img.shields.io/github/forks/BuningSoftware/win-uia.svg?style=for-the-badge +[forks-url]: https://github.com/BuningSoftware/win-uia/network/members +[stars-shield]: https://img.shields.io/github/stars/BuningSoftware/win-uia.svg?style=for-the-badge +[stars-url]: https://github.com/BuningSoftware/win-uia/stargazers +[issues-shield]: https://img.shields.io/github/issues/BuningSoftware/win-uia.svg?style=for-the-badge +[issues-url]: https://github.com/BuningSoftware/win-uia/issues +[license-shield]: https://img.shields.io/github/license/BuningSoftware/win-uia.svg?style=for-the-badge +[license-url]: https://github.com/BuningSoftware/win-uia/blob/main/LICENSE diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml new file mode 100644 index 0000000..ddf8693 --- /dev/null +++ b/.github/workflows/publish.yaml @@ -0,0 +1,65 @@ +name: Publish + +# Publishing a GitHub release publishes the WinUia packages to nuget.org, versioned from the release tag: +# v1.2.0 -> 1.2.0, v1.3.0-beta.1 -> 1.3.0-beta.1. +on: + release: + types: [published] + +permissions: + contents: read + +jobs: + # The same build and tests as a pull request; nothing is published unless they pass. + test: + name: Build and test + uses: ./.github/workflows/build-and-test.yaml + + publish: + name: Publish to NuGet + needs: test + runs-on: windows-latest + environment: nuget + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v6 + with: + dotnet-version: '10.0.x' + + - name: Version from the release tag + id: version + shell: bash + env: + TAG: ${{ github.event.release.tag_name }} + run: | + version="${TAG#v}" + if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then + echo "::error::Release tag '$TAG' is not a version such as v1.2.0 or v1.3.0-beta.1." + exit 1 + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + + # Packs the packable projects only: the modules under src/ (tests and examples are not packable). + - name: Pack + run: >- + dotnet pack WinUia.slnx --configuration Release --output packages + -p:Version=${{ steps.version.outputs.version }} -p:ContinuousIntegrationBuild=true + + - name: Keep the packages with the run + uses: actions/upload-artifact@v7 + with: + name: packages + path: packages + if-no-files-found: error + + # Pushes every .nupkg; nuget.org picks up the .snupkg symbol package next to each one. + - name: Push to nuget.org + shell: bash + run: >- + dotnet nuget push "packages/*.nupkg" + --api-key "${{ secrets.NUGET_API_KEY }}" + --source https://api.nuget.org/v3/index.json + --skip-duplicate 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/Tests/DialogTests.cs b/examples/WinUia.Examples.Winforms.Tests/DialogTests.cs similarity index 69% rename from examples/WinUia.Examples.Winforms.Tests/Tests/DialogTests.cs rename to examples/WinUia.Examples.Winforms.Tests/DialogTests.cs index 663d184..6b8a048 100644 --- a/examples/WinUia.Examples.Winforms.Tests/Tests/DialogTests.cs +++ b/examples/WinUia.Examples.Winforms.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; +namespace WinUia.Examples.Winforms.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/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/MainFormTests.cs b/examples/WinUia.Examples.Winforms.Tests/MainFormTests.cs new file mode 100644 index 0000000..a68b5ce --- /dev/null +++ b/examples/WinUia.Examples.Winforms.Tests/MainFormTests.cs @@ -0,0 +1,107 @@ +using WinUia.Core; +using WinUia.Core.Patterns; +using WinUia.Examples.Winforms.Tests.Application; + +namespace WinUia.Examples.Winforms.Tests; + +[UiTest] +public sealed class MainFormTests +{ + private WinFormsApp _app = null!; + + [SetUp] + public void SetUp() => _app = App.Launch(); + + [Test] + public void Launch_returns_the_main_form() + { + using (Assert.EnterMultipleScope()) + { + 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(_app.Find("Click me").AutomationId, Is.EqualTo("btnClick")); + } + + [Test] + public void The_form_starts_in_its_initial_state() + { + using (Assert.EnterMultipleScope()) + { + 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(_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 = _app.ItemsList.Find(e => e.Name == "Beta"); + + beta.Select(); + + Assert.That(beta.SelectionItemPattern.IsSelected, Is.True); + } + + [Test] + public void Recreate_increments_the_volatile_button_generation() + { + _app.RecreateButton.Click(); + Eventually(() => _app.VolatileButton.Name == "Volatile 2"); + + _app.RecreateButton.Click(); + Eventually(() => _app.VolatileButton.Name == "Volatile 3"); + } + + [Test] + public void Reverse_twice_restores_the_item_order() + { + _app.ReverseButton.Click(); + Eventually(() => ItemNames().SequenceEqual(["Item C", "Item B", "Item A"])); + + _app.ReverseButton.Click(); + Eventually(() => ItemNames().SequenceEqual(["Item A", "Item B", "Item C"])); + } + + [Test] + public void Clicking_the_button_updates_the_label() + { + _app.Button.Click(); + + Eventually(() => _app.ResultLabel.Name == "Clicked"); + } + + [Test] + public void SetValue_round_trips() + { + var input = _app.InputBox; + + input.SetValue("round trip"); + + Assert.That(input.ValuePattern.Value, Is.EqualTo("round trip")); + } + + [Test] + public void Toggle_changes_the_checkbox() + { + var toggle = _app.ToggleCheckBox; + + toggle.Toggle(); + + Assert.That(toggle.TogglePattern.State, Is.EqualTo(ToggleState.On)); + } + + [TearDown] + public void TearDown() => _app.Dispose(); + + 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/PatternTests.cs similarity index 53% rename from examples/WinUia.Examples.Winforms.Tests/Tests/PatternTests.cs rename to examples/WinUia.Examples.Winforms.Tests/PatternTests.cs index cf226b9..eb163d2 100644 --- a/examples/WinUia.Examples.Winforms.Tests/Tests/PatternTests.cs +++ b/examples/WinUia.Examples.Winforms.Tests/PatternTests.cs @@ -2,23 +2,21 @@ 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; +namespace WinUia.Examples.Winforms.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!; + 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 +26,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 +37,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 +54,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 +69,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 +84,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 +99,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 +144,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 +152,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,15 +164,60 @@ 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; + _app.Context.Input.MoveTo(5, 5); + + _app.Button.Click(); + var afterClick = _app.Context.Input.GetCursorPosition(); + _app.InputBox.SetValue("pointed at"); + var afterSetValue = _app.Context.Input.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. + _app.Context.Input.MoveTo(5, 5); // Away from the app's window. + + _app.Button.Click(); + var afterClick = _app.Context.Input.GetCursorPosition(); + _app.InputBox.SetValue("not pointed at"); + var afterSetValue = _app.Context.Input.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"); - Win32InputSimulator.SendText("typed"); + _app.Context.Input.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/SelfHealingTests.cs similarity index 71% rename from examples/WinUia.Examples.Winforms.Tests/Tests/SelfHealingTests.cs rename to examples/WinUia.Examples.Winforms.Tests/SelfHealingTests.cs index a155707..2fb9f46 100644 --- a/examples/WinUia.Examples.Winforms.Tests/Tests/SelfHealingTests.cs +++ b/examples/WinUia.Examples.Winforms.Tests/SelfHealingTests.cs @@ -1,9 +1,8 @@ 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; +namespace WinUia.Examples.Winforms.Tests; /// /// Elements re-find themselves when their control is re-created: FindFirst results through their search, FindAll @@ -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/TabTests.cs similarity index 53% rename from examples/WinUia.Examples.Winforms.Tests/Tests/TabTests.cs rename to examples/WinUia.Examples.Winforms.Tests/TabTests.cs index 1d8ccb9..13e9891 100644 --- a/examples/WinUia.Examples.Winforms.Tests/Tests/TabTests.cs +++ b/examples/WinUia.Examples.Winforms.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; +namespace WinUia.Examples.Winforms.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.Tests/Tests/MainFormTests.cs b/examples/WinUia.Examples.Winforms.Tests/Tests/MainFormTests.cs deleted file mode 100644 index d51bd8d..0000000 --- a/examples/WinUia.Examples.Winforms.Tests/Tests/MainFormTests.cs +++ /dev/null @@ -1,108 +0,0 @@ -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/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 diff --git a/src/WinUia/App.cs b/src/WinUia/App.cs index 3fc4586..1cc0ecd 100644 --- a/src/WinUia/App.cs +++ b/src/WinUia/App.cs @@ -12,9 +12,9 @@ namespace WinUia; /// using var app = App.Launch(@"C:\path\to\MyApp.exe"); /// app.Find("btnOk").Click(); /// -/// A page object for the whole application derives from and lists its controls; get one with -/// , for example App.Launch(path).As<MainPage>(). Page objects for part of the UI -/// are plain classes built on an , for example new SettingsDialog(app.FindWindow("Settings")). +/// A page object for the whole application derives from and lists its controls; the generic factories +/// create it already connected: App.Launch<MainPage>(path, options). Page objects for part of the UI are +/// plain classes built on an , for example new SettingsDialog(app.FindWindow("Settings")). /// /// Must be used from an MTA thread (see ); creating it on an STA thread throws. /// Disposing an app that was launched closes it; disposing an attached app leaves it running. @@ -25,30 +25,82 @@ public class App : IDisposable private Process? _process; private AutomationContext? _context; private bool _ownsProcess; - private bool _handedOver; // True once As moved this app to another instance. private bool _disposed; /// - /// For derived types, which are not created directly but through ; that connects the instance - /// to a running application. + /// For derived types (page objects), which are created by the generic factories such as + /// ; those connect the instance to the application. The constructor + /// runs before the application starts, so it cannot use or . /// protected App() { } + /// + /// The executable a page object launches when is called without a + /// path. Null (the default) means the page object does not know its executable, so a path must be passed. + /// + protected virtual string? ExecutablePath => null; + + /// + /// How a page object is launched or attached unless the caller says otherwise: the options passed to a factory + /// override these property by property, and an option left empty in both keeps WinUia's default. Attaching uses + /// only and . + /// + protected virtual AppLaunchOptions DefaultOptions => new(); + /// Starts a classic executable. Throws when it cannot be started. - public static App Launch(string path, string? arguments = null) => - Open(() => AppLauncher.LaunchExe(path, arguments), ownsProcess: true); + public static App Launch(string path, AppLaunchOptions? options = null) => + Start(new App(), effective => AppLauncher.LaunchExe(path, effective), options); - /// Starts a packaged app by its AppUserModelID. Throws when it cannot be activated. - public static App LaunchPackaged(string appUserModelId, string? arguments = null) => - Open(() => AppLauncher.LaunchPackaged(appUserModelId, arguments), ownsProcess: true); + /// + /// Starts a classic executable as , a page object that derives from : + /// App.Launch<MainPage>(path). Throws when it cannot be started. + /// + public static TApp Launch(string path, AppLaunchOptions? options = null) where TApp : App, new() => + Start(new TApp(), effective => AppLauncher.LaunchExe(path, effective), options); + + /// + /// Starts the executable declares () as that page object: + /// App.Launch<MainPage>(). Throws , before anything starts, + /// when does not declare one, and when it cannot be started. + /// + public static TApp Launch(AppLaunchOptions? options = null) where TApp : App, new() + { + var app = new TApp(); + App definition = app; // Protected members are reachable through the base type here. + var path = definition.ExecutablePath ?? throw new InvalidOperationException( + $"{typeof(TApp).Name} does not say which executable it launches. Override {nameof(ExecutablePath)} in " + + $"{typeof(TApp).Name}, or pass the path: App.Launch<{typeof(TApp).Name}>(path)."); + + return Start(app, effective => AppLauncher.LaunchExe(path, effective), options); + } + + /// + /// Starts a packaged app by its AppUserModelID. Throws when it cannot be activated, + /// and for or + /// , which Windows decides for packaged apps. + /// + public static App LaunchPackaged(string appUserModelId, AppLaunchOptions? options = null) => + Start(new App(), effective => AppLauncher.LaunchPackaged(appUserModelId, effective), options, packaged: true); + + /// Like , as . + public static TApp LaunchPackaged(string appUserModelId, AppLaunchOptions? options = null) where TApp : App, new() => + Start(new TApp(), effective => AppLauncher.LaunchPackaged(appUserModelId, effective), options, packaged: true); /// Attaches to a running process by id. Throws when it is not running. public static App Attach(int processId) => - Open(() => AppLauncher.Attach(processId), ownsProcess: false); + Start(new App(), _ => AppLauncher.Attach(processId), options: null, ownsProcess: false); + + /// Like , as . + public static TApp Attach(int processId) where TApp : App, new() => + Start(new TApp(), _ => AppLauncher.Attach(processId), options: null, ownsProcess: false); /// Attaches to a running process by name (without ".exe"). Throws when none is running. public static App Attach(string processName) => - Open(() => AppLauncher.Attach(processName), ownsProcess: false); + Start(new App(), _ => AppLauncher.Attach(processName), options: null, ownsProcess: false); + + /// Like , as . + public static TApp Attach(string processName) where TApp : App, new() => + Start(new TApp(), _ => AppLauncher.Attach(processName), options: null, ownsProcess: false); /// The automation context used for this app. public AutomationContext Context => _context ?? throw NotConnected(); @@ -62,27 +114,6 @@ public static App Attach(string processName) => /// The application's main window, waited for on first use. public Element MainWindow => field ??= AppLauncher.GetMainWindow(Context, Process, MainWindowTimeout); - /// - /// This running application as , a page object that derives from : - /// App.Launch(path).As<MainPage>(). The returned instance takes the application over: dispose that one. - /// Disposing this instance afterwards does nothing, so a launched application is closed exactly once. - /// - public TApp As() where TApp : App, new() - { - ObjectDisposedException.ThrowIf(_disposed, this); - if (_handedOver) - throw new InvalidOperationException("This app was already handed over by As(); use the instance it returned."); - - var app = new TApp(); - App connected = app; // Private members are reachable through the base type only. - connected._process = Process; - connected._context = Context; - connected._ownsProcess = _ownsProcess; - connected.MainWindowTimeout = MainWindowTimeout; - _handedOver = true; - return app; - } - /// /// Waits for a descendant of the main window whose AutomationId, or failing that Name, equals /// (). @@ -139,8 +170,8 @@ protected virtual void Dispose(bool disposing) return; _disposed = true; - // Not connected (created directly), or handed over to the instance As returned: nothing to release here. - if (_handedOver || _process is null || _context is null) + // Created directly instead of through a factory: not connected, nothing to release. + if (_process is null || _context is null) return; try @@ -156,15 +187,31 @@ protected virtual void Dispose(bool disposing) } /// - /// Creates the context, then starts or attaches to the process. The context comes first so that an STA thread - /// fails before anything is started that would have to be killed again. + /// Connects , already constructed, to the application starts or + /// attaches to, with over the page object's . The context is + /// created before the process, so that an STA thread or invalid options fail before anything is started that would + /// have to be killed again. /// - private static App Open(Func connect, bool ownsProcess) + private static TApp Start(TApp app, Func connect, AppLaunchOptions? options, + bool ownsProcess = true, bool packaged = false) where TApp : App { + App connected = app; // Private and protected members are reachable through the base type only. + var effective = connected.DefaultOptions.OverriddenBy(options); + if (packaged) + AppLauncher.EnsurePackagedOptions(effective); + var context = new AutomationContext(); try { - return new App { _process = connect(), _context = context, _ownsProcess = ownsProcess }; + if (effective.ShowPointer is { } showPointer) + context.ShowPointer = showPointer; + if (effective.MainWindowTimeout is { } mainWindowTimeout) + connected.MainWindowTimeout = mainWindowTimeout; + + connected._process = connect(effective); + connected._context = context; + connected._ownsProcess = ownsProcess; + return app; } catch { @@ -174,5 +221,5 @@ private static App Open(Func connect, bool ownsProcess) } private static InvalidOperationException NotConnected() => - new("This app is not connected to an application. Get it with App.Launch(...).As() or App.Attach(...).As()."); + new("This app is not connected to an application. Create it with App.Launch(...), App.LaunchPackaged(...) or App.Attach(...)."); } diff --git a/src/WinUia/AppLaunchOptions.cs b/src/WinUia/AppLaunchOptions.cs new file mode 100644 index 0000000..6eb41ab --- /dev/null +++ b/src/WinUia/AppLaunchOptions.cs @@ -0,0 +1,43 @@ +namespace WinUia; + +/// +/// How and +/// start an application. Every option is optional: one left out keeps the page object's default (its +/// DefaultOptions), and otherwise WinUia's. +/// +/// using var app = App.Launch<MainPage>(new AppLaunchOptions { Arguments = "--demo", ShowPointer = true }); +/// +/// +public sealed record AppLaunchOptions +{ + /// Command-line arguments for the application. + public string? Arguments { get; init; } + + /// The directory the application starts in. Executables only: packaged apps start where Windows starts them. + public string? WorkingDirectory { get; init; } + + /// + /// Environment variables to set for the application, on top of this process's own; a null value removes a variable. + /// Executables only: packaged apps get their environment from Windows. + /// + public IReadOnlyDictionary? Environment { get; init; } + + /// + /// Whether interactions move the cursor to each element first (). + /// Null keeps the default, which the WINUIA_SHOW_POINTER environment variable can switch on. + /// + public bool? ShowPointer { get; init; } + + /// How long waits for the window to appear. Null keeps the default (20 seconds). + public TimeSpan? MainWindowTimeout { get; init; } + + /// These options, with every option sets taken from there instead. + internal AppLaunchOptions OverriddenBy(AppLaunchOptions? overrides) => overrides is null ? this : new AppLaunchOptions + { + Arguments = overrides.Arguments ?? Arguments, + WorkingDirectory = overrides.WorkingDirectory ?? WorkingDirectory, + Environment = overrides.Environment ?? Environment, + ShowPointer = overrides.ShowPointer ?? ShowPointer, + MainWindowTimeout = overrides.MainWindowTimeout ?? MainWindowTimeout, + }; +} diff --git a/src/WinUia/Launchers/AppLauncher.cs b/src/WinUia/Launchers/AppLauncher.cs index 0c7d2c7..793bea0 100644 --- a/src/WinUia/Launchers/AppLauncher.cs +++ b/src/WinUia/Launchers/AppLauncher.cs @@ -8,13 +8,26 @@ namespace WinUia.Launchers; /// Starts, attaches to and closes applications, and finds their main window. is the public API over it. internal static class AppLauncher { - /// Starts a classic executable (WinForms, WPF, unpackaged WinUI 3, Win32; x86 or x64). - public static Process LaunchExe(string path, string? arguments = null) + /// + /// Starts a classic executable (WinForms, WPF, unpackaged WinUI 3, Win32; x86 or x64) with the arguments, working + /// directory and environment of . + /// + public static Process LaunchExe(string path, AppLaunchOptions? options = null) { + var startInfo = new ProcessStartInfo(path, options?.Arguments ?? "") { UseShellExecute = false }; + if (options?.WorkingDirectory is { } workingDirectory) + startInfo.WorkingDirectory = workingDirectory; + foreach (var (name, value) in options?.Environment ?? new Dictionary()) + { + if (value is null) + startInfo.Environment.Remove(name); + else + startInfo.Environment[name] = value; + } + try { - return Process.Start(new ProcessStartInfo(path, arguments ?? "") { UseShellExecute = false }) - ?? throw new AppProcessException($"Could not start '{path}'."); + return Process.Start(startInfo) ?? throw new AppProcessException($"Could not start '{path}'."); } catch (Win32Exception ex) { @@ -22,9 +35,25 @@ public static Process LaunchExe(string path, string? arguments = null) } } - /// Starts a packaged app by its AppUserModelID, for example "Microsoft.WindowsCalculator_8wekyb3d8bbwe!App". - public static Process LaunchPackaged(string appUserModelId, string? arguments = null) => - Attach(PackagedAppActivator.Activate(appUserModelId, arguments)); + /// + /// Starts a packaged app by its AppUserModelID, for example "Microsoft.WindowsCalculator_8wekyb3d8bbwe!App", + /// with the arguments of . Windows decides a packaged app's working directory and + /// environment, so asking for either throws . + /// + public static Process LaunchPackaged(string appUserModelId, AppLaunchOptions? options = null) + { + EnsurePackagedOptions(options); + return Attach(PackagedAppActivator.Activate(appUserModelId, options?.Arguments)); + } + + /// Throws for options a packaged app cannot be started with. + public static void EnsurePackagedOptions(AppLaunchOptions? options) + { + if (options?.WorkingDirectory is not null || options?.Environment is not null) + throw new ArgumentException( + "A packaged app starts with the working directory and environment Windows gives it; " + + "WorkingDirectory and Environment apply to executables only.", nameof(options)); + } /// Attaches to a running process by id. public static Process Attach(int processId) diff --git a/src/WinUia/Launchers/PackagedAppActivator.cs b/src/WinUia/Launchers/PackagedAppActivator.cs index 0d5456b..cdb4f76 100644 --- a/src/WinUia/Launchers/PackagedAppActivator.cs +++ b/src/WinUia/Launchers/PackagedAppActivator.cs @@ -11,6 +11,7 @@ internal static class PackagedAppActivator { public static int Activate(string appUserModelId, string? arguments) { + // ReSharper disable once SuspiciousTypeConversion.Global (a COM coclass: the object behind it implements the interface) var manager = (IApplicationActivationManager)new ApplicationActivationManager(); try { diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props index de571be..18a9ba3 100644 --- a/tests/Directory.Build.props +++ b/tests/Directory.Build.props @@ -3,10 +3,11 @@ false - true + + true - + @@ -35,6 +36,14 @@ + + + + + + + + + diff --git a/tests/WinUia.Core.UiTests/WinUia.Core.UiTests.csproj b/tests/WinUia.Core.UiTests/WinUia.Core.UiTests.csproj new file mode 100644 index 0000000..f8e7ff1 --- /dev/null +++ b/tests/WinUia.Core.UiTests/WinUia.Core.UiTests.csproj @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/tests/WinUia.Core.UiTests/WindowPropertyTests.cs b/tests/WinUia.Core.UiTests/WindowPropertyTests.cs new file mode 100644 index 0000000..63732d5 --- /dev/null +++ b/tests/WinUia.Core.UiTests/WindowPropertyTests.cs @@ -0,0 +1,38 @@ +using WinUia.Testing.Shared; + +namespace WinUia.Core.UiTests; + +/// +/// A real application window reports the same values through Element's typed properties and through the generic +/// GetPropertyValue, so the hand-written vtable slots behind them line up for windows too (the desktop root is +/// covered by the integration tests). +/// +public class WindowPropertyTests +{ + [Test] + public void A_window_reports_the_same_values_through_typed_properties_and_GetPropertyValue() + { + using var app = App.Launch(AppPaths.TestApp); + var window = app.MainWindow; + + using (Assert.EnterMultipleScope()) + { + Assert.That(window.Name, Is.EqualTo(window.GetPropertyValue(30005) ?? "")); // Name + Assert.That(window.AutomationId, Is.EqualTo(window.GetPropertyValue(30011) ?? "")); // AutomationId + Assert.That(window.ClassName, Is.EqualTo(window.GetPropertyValue(30012) ?? "")); // ClassName + Assert.That(window.FrameworkId, Is.EqualTo(window.GetPropertyValue(30024) ?? "")); // FrameworkId + Assert.That((int)window.ControlType, Is.EqualTo(window.GetPropertyValue(30003))); // ControlType + Assert.That(window.ProcessId, Is.EqualTo(window.GetPropertyValue(30002))); // ProcessId + Assert.That(window.IsEnabled, Is.EqualTo(window.GetPropertyValue(30010))); // IsEnabled + Assert.That(window.IsOffscreen, Is.EqualTo(window.GetPropertyValue(30022))); // IsOffscreen + Assert.That((long)window.NativeWindowHandle, Is.EqualTo(Convert.ToInt64(window.GetPropertyValue(30020)))); // NativeWindowHandle + + var bounds = (double[])window.GetPropertyValue(30001)!; // BoundingRectangle: l, t, w, h + var rect = window.BoundingRectangle; + Assert.That(rect.Left, Is.EqualTo((int)bounds[0])); + Assert.That(rect.Top, Is.EqualTo((int)bounds[1])); + Assert.That(rect.Right, Is.EqualTo((int)(bounds[0] + bounds[2]))); + Assert.That(rect.Bottom, Is.EqualTo((int)(bounds[1] + bounds[3]))); + } + } +} diff --git a/tests/WinUia.Core.UnitTests/ElementPredicateTests.cs b/tests/WinUia.Core.UnitTests/ElementPredicateTests.cs index a983e49..4ea980e 100644 --- a/tests/WinUia.Core.UnitTests/ElementPredicateTests.cs +++ b/tests/WinUia.Core.UnitTests/ElementPredicateTests.cs @@ -22,6 +22,7 @@ public void Reads_captured_values_on_either_side_when_canonicalized() var name = "Tab 1"; var processId = 42; + // ReSharper disable once AccessToModifiedClosure (the point of the test: the change after canonicalizing is not seen) var search = ElementPredicate.Canonicalize(e => name == e.Name && e.ProcessId == processId); name = "changed"; @@ -85,14 +86,4 @@ public void Rejects_what_UIA_cannot_search_on() Assert.Throws(() => ElementPredicate.Canonicalize(e => e.Name == null!)); } } - - [Test] - public void Every_supported_form_becomes_a_UIA_condition() - { - using var context = new AutomationContext(); - var search = ElementPredicate.Canonicalize(e => - (e.AutomationId == "a" && e.ProcessId == 1) || e.ClassName != "c" || e.ControlType == ControlType.Pane || !(e.Name == "n")); - - Assert.That(context.CreateCondition(search), Is.Not.Null); - } } diff --git a/tests/WinUia.Core.UnitTests/WinUia.Core.UnitTests.csproj b/tests/WinUia.Core.UnitTests/WinUia.Core.UnitTests.csproj index 92c64b4..345cef8 100644 --- a/tests/WinUia.Core.UnitTests/WinUia.Core.UnitTests.csproj +++ b/tests/WinUia.Core.UnitTests/WinUia.Core.UnitTests.csproj @@ -1,12 +1,8 @@ - + - - - - diff --git a/tests/WinUia.Input.UnitTests/PhysicalDpiTests.cs b/tests/WinUia.Input.IntegrationTests/PhysicalDpiTests.cs similarity index 86% rename from tests/WinUia.Input.UnitTests/PhysicalDpiTests.cs rename to tests/WinUia.Input.IntegrationTests/PhysicalDpiTests.cs index b1ed277..31b2c0b 100644 --- a/tests/WinUia.Input.UnitTests/PhysicalDpiTests.cs +++ b/tests/WinUia.Input.IntegrationTests/PhysicalDpiTests.cs @@ -1,10 +1,10 @@ using System.Runtime.InteropServices; -namespace WinUia.Input.UnitTests; +namespace WinUia.Input.IntegrationTests; public class PhysicalDpiTests { - private const int DPI_AWARENESS_PER_MONITOR_AWARE = 2; + private const int DpiAwarenessPerMonitorAware = 2; [DllImport("user32.dll")] private static extern nint GetThreadDpiAwarenessContext(); @@ -23,7 +23,7 @@ public void Run_is_per_monitor_aware_inside_and_restores_the_thread_context_afte using (Assert.EnterMultipleScope()) { - Assert.That(inside, Is.EqualTo(DPI_AWARENESS_PER_MONITOR_AWARE)); + Assert.That(inside, Is.EqualTo(DpiAwarenessPerMonitorAware)); Assert.That(CurrentAwareness(), Is.EqualTo(before)); } } diff --git a/tests/WinUia.Input.IntegrationTests/WinUia.Input.IntegrationTests.csproj b/tests/WinUia.Input.IntegrationTests/WinUia.Input.IntegrationTests.csproj new file mode 100644 index 0000000..c336709 --- /dev/null +++ b/tests/WinUia.Input.IntegrationTests/WinUia.Input.IntegrationTests.csproj @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/tests/WinUia.Input.UiTests/CursorTests.cs b/tests/WinUia.Input.UiTests/CursorTests.cs new file mode 100644 index 0000000..d354ef5 --- /dev/null +++ b/tests/WinUia.Input.UiTests/CursorTests.cs @@ -0,0 +1,59 @@ +using System.Diagnostics; + +namespace WinUia.Input.UiTests; + +/// Moves the real cursor, so these run as UI tests (one desktop, interactive session). +public class CursorTests +{ + private readonly Win32InputSimulator _input = new(); + private (int X, int Y) _original; + + [SetUp] + public void RememberCursor() => _original = _input.GetCursorPosition(); + + [TearDown] + public void RestoreCursor() => _input.MoveTo(_original.X, _original.Y); + + [Test] + public void MoveTo_puts_the_cursor_where_GetCursorPosition_reports_it() + { + _input.MoveTo(210, 160); + + Assert.That(_input.GetCursorPosition(), Is.EqualTo((210, 160))); + } + + [Test] + public void A_smooth_move_ends_exactly_on_the_target_after_about_its_duration() + { + _input.MoveTo(200, 200); + var stopwatch = Stopwatch.StartNew(); + + _input.MoveTo(500, 350, TimeSpan.FromMilliseconds(300)); + + using (Assert.EnterMultipleScope()) + { + Assert.That(_input.GetCursorPosition(), Is.EqualTo((500, 350))); + Assert.That(stopwatch.ElapsedMilliseconds, Is.InRange(250, 3000)); + } + } + + [Test] + public void A_smooth_move_to_where_the_cursor_already_is_returns_at_once() + { + _input.MoveTo(300, 250); + var stopwatch = Stopwatch.StartNew(); + + _input.MoveTo(300, 250, TimeSpan.FromSeconds(2)); + + Assert.That(stopwatch.ElapsedMilliseconds, Is.LessThan(500)); + } + + [Test] + public void MoveTo_a_point_on_no_screen_throws_instead_of_leaving_the_cursor_elsewhere() + { + // Windows clamps the cursor to the screens, so it can never arrive at a point beyond all of them. + var ex = Assert.Throws(() => _input.MoveTo(100_000, 100_000)); + + Assert.That(ex.Message, Does.Contain("(100000, 100000)")); + } +} diff --git a/tests/WinUia.Input.UiTests/WinUia.Input.UiTests.csproj b/tests/WinUia.Input.UiTests/WinUia.Input.UiTests.csproj new file mode 100644 index 0000000..c336709 --- /dev/null +++ b/tests/WinUia.Input.UiTests/WinUia.Input.UiTests.csproj @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/tests/WinUia.Input.UnitTests/WinUia.Input.UnitTests.csproj b/tests/WinUia.Input.UnitTests/WinUia.Input.UnitTests.csproj index dff03c5..c336709 100644 --- a/tests/WinUia.Input.UnitTests/WinUia.Input.UnitTests.csproj +++ b/tests/WinUia.Input.UnitTests/WinUia.Input.UnitTests.csproj @@ -1,6 +1,7 @@ + diff --git a/tests/WinUia.IntegrationTests/AppLauncherTests.cs b/tests/WinUia.IntegrationTests/AppLauncherTests.cs new file mode 100644 index 0000000..6c2abc3 --- /dev/null +++ b/tests/WinUia.IntegrationTests/AppLauncherTests.cs @@ -0,0 +1,58 @@ +using WinUia.Core; +using WinUia.Launchers; + +namespace WinUia.IntegrationTests; + +/// Launcher failures: real processes and the real activation manager, but no window to drive. +public class AppLauncherTests +{ + [Test] + public void GetMainWindow_fails_fast_when_the_process_exits() + { + var ex = Assert.Throws(() => + { + using var context = new AutomationContext(); + using var process = AppLauncher.LaunchExe("cmd.exe", new AppLaunchOptions { Arguments = "/c exit 3" }); + AppLauncher.GetMainWindow(context, process, TimeSpan.FromSeconds(15)); + }); + Assert.That(ex.Message, Does.Contain("exited")); + } + + [Test] + public void LaunchExe_passes_the_working_directory_and_environment() + { + var directory = Directory.CreateTempSubdirectory("winuia-").FullName; + try + { + File.WriteAllText(Path.Combine(directory, "marker.txt"), ""); + // Exits 0 only when started in the directory with the marker and with the variable set; 1 otherwise. + var options = new AppLaunchOptions + { + Arguments = "/c if exist marker.txt (if \"%WINUIA_TEST_VARIABLE%\"==\"set\" (exit 0)) & exit 1", + WorkingDirectory = directory, + Environment = new Dictionary { ["WINUIA_TEST_VARIABLE"] = "set" }, + }; + + using var process = AppLauncher.LaunchExe("cmd.exe", options); + Assert.That(process.WaitForExit(10_000), Is.True); + Assert.That(process.ExitCode, Is.Zero); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + [Test] + public void Attach_to_a_missing_process_name_throws() + { + Assert.Throws(() => AppLauncher.Attach("WinUia.No.Such.Process")); + } + + [Test] + public void LaunchPackaged_with_an_unknown_AUMID_throws_an_AppProcessException() + { + var ex = Assert.Throws(() => AppLauncher.LaunchPackaged("WinUia.NoSuchPackage_0000000000000!App")); + Assert.That(ex.Message, Does.Contain("WinUia.NoSuchPackage")); + } +} diff --git a/tests/WinUia.IntegrationTests/AppTests.cs b/tests/WinUia.IntegrationTests/AppTests.cs new file mode 100644 index 0000000..9f93d5f --- /dev/null +++ b/tests/WinUia.IntegrationTests/AppTests.cs @@ -0,0 +1,10 @@ +namespace WinUia.IntegrationTests; + +public class AppTests +{ + [Test] + public void Attaching_to_a_missing_process_throws_an_AppProcessException() + { + Assert.Throws(() => App.Attach("WinUia.No.Such.Process")); + } +} diff --git a/tests/WinUia.IntegrationTests/WinUia.IntegrationTests.csproj b/tests/WinUia.IntegrationTests/WinUia.IntegrationTests.csproj new file mode 100644 index 0000000..3ff6d18 --- /dev/null +++ b/tests/WinUia.IntegrationTests/WinUia.IntegrationTests.csproj @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/tests/WinUia.NUnit.UnitTests/DesktopLockTests.cs b/tests/WinUia.NUnit.IntegrationTests/DesktopLockTests.cs similarity index 95% rename from tests/WinUia.NUnit.UnitTests/DesktopLockTests.cs rename to tests/WinUia.NUnit.IntegrationTests/DesktopLockTests.cs index 225890a..b0e5dac 100644 --- a/tests/WinUia.NUnit.UnitTests/DesktopLockTests.cs +++ b/tests/WinUia.NUnit.IntegrationTests/DesktopLockTests.cs @@ -1,4 +1,4 @@ -namespace WinUia.NUnit.UnitTests; +namespace WinUia.NUnit.IntegrationTests; public class DesktopLockTests { diff --git a/tests/WinUia.NUnit.IntegrationTests/WinUia.NUnit.IntegrationTests.csproj b/tests/WinUia.NUnit.IntegrationTests/WinUia.NUnit.IntegrationTests.csproj new file mode 100644 index 0000000..ce162d8 --- /dev/null +++ b/tests/WinUia.NUnit.IntegrationTests/WinUia.NUnit.IntegrationTests.csproj @@ -0,0 +1,5 @@ + + + + + diff --git a/tests/WinUia.NUnit.UnitTests/UiTestAttributeTests.cs b/tests/WinUia.NUnit.UiTests/UiTestAttributeTests.cs similarity index 81% rename from tests/WinUia.NUnit.UnitTests/UiTestAttributeTests.cs rename to tests/WinUia.NUnit.UiTests/UiTestAttributeTests.cs index 029befb..3f30fea 100644 --- a/tests/WinUia.NUnit.UnitTests/UiTestAttributeTests.cs +++ b/tests/WinUia.NUnit.UiTests/UiTestAttributeTests.cs @@ -1,4 +1,4 @@ -namespace WinUia.NUnit.UnitTests; +namespace WinUia.NUnit.UiTests; [UiTest] public class UiTestAttributeTests @@ -28,6 +28,11 @@ private static bool DesktopIsHeld() [SetUp] public void SetUp() => _desktopHeldDuringSetUp = DesktopIsHeld(); + // TearDown typically closes the app or restores the cursor: with the lock already released, that would happen in + // the middle of another process's UI test. + [TearDown] + public void TearDown() => Assert.That(DesktopIsHeld(), Is.True, "the lock is still held while [TearDown] runs"); + [Test] public void The_desktop_is_held_during_SetUp_and_the_test() { diff --git a/tests/WinUia.NUnit.UiTests/WinUia.NUnit.UiTests.csproj b/tests/WinUia.NUnit.UiTests/WinUia.NUnit.UiTests.csproj new file mode 100644 index 0000000..ce162d8 --- /dev/null +++ b/tests/WinUia.NUnit.UiTests/WinUia.NUnit.UiTests.csproj @@ -0,0 +1,5 @@ + + + + + diff --git a/tests/WinUia.UnitTests/AppLauncherTests.cs b/tests/WinUia.UiTests/AppLauncherTests.cs similarity index 75% rename from tests/WinUia.UnitTests/AppLauncherTests.cs rename to tests/WinUia.UiTests/AppLauncherTests.cs index b901aef..5490176 100644 --- a/tests/WinUia.UnitTests/AppLauncherTests.cs +++ b/tests/WinUia.UiTests/AppLauncherTests.cs @@ -3,9 +3,8 @@ using WinUia.Launchers; using WinUia.Testing.Shared; -namespace WinUia.UnitTests; +namespace WinUia.UiTests; -[UiTest] public sealed class AppLauncherTests { private AutomationContext _context = null!; @@ -52,15 +51,6 @@ public void LaunchExe_and_GetMainWindow_find_the_fixture_window() } } - [Test] - public void GetMainWindow_fails_fast_when_the_process_exits() - { - var process = Track(AppLauncher.LaunchExe("cmd.exe", "/c exit 3")); - - var ex = Assert.Throws(() => AppLauncher.GetMainWindow(_context, process, TimeSpan.FromSeconds(15))); - Assert.That(ex.Message, Does.Contain("exited")); - } - [Test] public void Close_closes_the_window_gracefully() { @@ -79,7 +69,7 @@ public void Close_closes_the_window_gracefully() [Test] public void Close_kills_a_window_that_refuses_to_close() { - var process = Track(AppLauncher.LaunchExe(AppPaths.TestApp, "--ignore-close")); + var process = Track(AppLauncher.LaunchExe(AppPaths.TestApp, new AppLaunchOptions { Arguments = "--ignore-close" })); AppLauncher.GetMainWindow(_context, process, TimeSpan.FromSeconds(15)); AppLauncher.Close(_context, process, TimeSpan.FromSeconds(1)); @@ -106,17 +96,4 @@ public void Attach_finds_a_running_process_by_id_and_name() Assert.That(byName.ProcessName, Is.EqualTo("WinUia.TestApp")); } } - - [Test] - public void Attach_to_a_missing_process_name_throws() - { - Assert.Throws(() => AppLauncher.Attach("WinUia.No.Such.Process")); - } - - [Test] - public void LaunchPackaged_with_an_unknown_AUMID_throws_an_AppProcessException() - { - var ex = Assert.Throws(() => AppLauncher.LaunchPackaged("WinUia.NoSuchPackage_0000000000000!App")); - Assert.That(ex.Message, Does.Contain("WinUia.NoSuchPackage")); - } } diff --git a/tests/WinUia.UiTests/AppTests.cs b/tests/WinUia.UiTests/AppTests.cs new file mode 100644 index 0000000..5526652 --- /dev/null +++ b/tests/WinUia.UiTests/AppTests.cs @@ -0,0 +1,135 @@ +using System.Diagnostics; +using WinUia.Core.Exceptions; +using WinUia.Testing.Shared; + +namespace WinUia.UiTests; + +/// +/// Behaviour of itself: main window, Find timeouts, closing and ownership, against the empty test app. +/// Finding controls and windows by content is tested in the WinForms example. +/// +public sealed class AppTests +{ + private App _app = null!; + + [SetUp] + public void LaunchApp() => _app = App.Launch(AppPaths.TestApp); + + [TearDown] + public void CloseApp() => _app.Dispose(); + + [Test] + public void Launch_finds_the_main_window() + { + using (Assert.EnterMultipleScope()) + { + Assert.That(_app.MainWindow.Name, Is.EqualTo("WinUia Test App")); + Assert.That(_app.MainWindow.ProcessId, Is.EqualTo(_app.Process.Id)); + } + } + + [Test] + public void Find_of_a_missing_element_throws_after_the_timeout_and_TryFind_returns_null() + { + _ = _app.MainWindow; // The first Find also waits for the window to appear; keep that out of the timing. + + var stopwatch = Stopwatch.StartNew(); + Assert.Throws(() => _app.Find("doesNotExist", TimeSpan.FromMilliseconds(300))); + Assert.That(stopwatch.ElapsedMilliseconds, Is.InRange(290, 5000)); + + Assert.That(_app.TryFind("doesNotExist", TimeSpan.FromMilliseconds(100)), Is.Null); + } + + [Test] + public void Close_ends_the_process() + { + _app.Close(); + + Assert.That(_app.Process.HasExited, Is.True); + } + + [Test] + public void Dispose_closes_a_launched_app_but_not_an_attached_one() + { + using (var attached = App.Attach(_app.Process.Id)) + { + Assert.That(attached.MainWindow.Name, Is.EqualTo("WinUia Test App")); + } + + Assert.That(_app.Process.HasExited, Is.False); + + var pid = _app.Process.Id; + _app.Dispose(); + Assert.Throws(() => Process.GetProcessById(pid)); + } + + [Test] + public void The_generic_factories_return_the_page_object_connected() + { + using var launched = App.Launch(AppPaths.TestApp); + using (var attached = App.Attach(launched.Process.Id)) + { + using (Assert.EnterMultipleScope()) + { + Assert.That(launched.Title, Is.EqualTo("WinUia Test App")); + Assert.That(attached.Title, Is.EqualTo("WinUia Test App")); + } + } + + Assert.That(launched.Process.HasExited, Is.False, "disposing the attached page object leaves the app running"); + } + + [Test] + public void Launch_applies_the_options_to_the_app() + { + using var app = App.Launch(AppPaths.TestApp, new AppLaunchOptions + { + ShowPointer = true, + MainWindowTimeout = TimeSpan.FromSeconds(7), + }); + + using (Assert.EnterMultipleScope()) + { + Assert.That(app.Context.ShowPointer, Is.True); + Assert.That(app.MainWindowTimeout, Is.EqualTo(TimeSpan.FromSeconds(7))); + Assert.That(app.Title, Is.EqualTo("WinUia Test App")); + } + } + + [Test] + public void Launch_with_arguments_passes_them_to_the_app() + { + // The test app refuses to close with --ignore-close, so Close has to fall back to killing it. + using var app = App.Launch(AppPaths.TestApp, new AppLaunchOptions { Arguments = "--ignore-close" }); + _ = app.MainWindow; + + app.Close(TimeSpan.FromSeconds(1)); + + Assert.That(app.Process.ExitCode, Is.Not.Zero); + } + + [Test] + public void Launch_without_a_path_uses_the_page_objects_executable_and_defaults() + { + using var app = App.Launch(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(app.Title, Is.EqualTo("WinUia Test App")); + Assert.That(app.Context.ShowPointer, Is.True); + Assert.That(app.MainWindowTimeout, Is.EqualTo(TimeSpan.FromSeconds(7))); + } + } + + [Test] + public void Options_passed_to_Launch_override_the_page_objects_defaults() + { + using var app = App.Launch(new AppLaunchOptions { ShowPointer = false }); + + using (Assert.EnterMultipleScope()) + { + Assert.That(app.Context.ShowPointer, Is.False, "overridden"); + Assert.That(app.MainWindowTimeout, Is.EqualTo(TimeSpan.FromSeconds(7)), "kept from the defaults"); + } + } +} diff --git a/tests/WinUia.UnitTests/DerivedApp.cs b/tests/WinUia.UiTests/DerivedApp.cs similarity index 88% rename from tests/WinUia.UnitTests/DerivedApp.cs rename to tests/WinUia.UiTests/DerivedApp.cs index 0803310..6572431 100644 --- a/tests/WinUia.UnitTests/DerivedApp.cs +++ b/tests/WinUia.UiTests/DerivedApp.cs @@ -1,4 +1,4 @@ -namespace WinUia.UnitTests; +namespace WinUia.UiTests; /// A page object that derives from , the way a user's main-window page object would. public sealed class DerivedApp : App diff --git a/tests/WinUia.UiTests/LaunchableApp.cs b/tests/WinUia.UiTests/LaunchableApp.cs new file mode 100644 index 0000000..0efda48 --- /dev/null +++ b/tests/WinUia.UiTests/LaunchableApp.cs @@ -0,0 +1,13 @@ +using WinUia.Testing.Shared; + +namespace WinUia.UiTests; + +/// A page object that knows its own executable and defaults, so it launches with App.Launch<LaunchableApp>(). +public sealed class LaunchableApp : App +{ + protected override string ExecutablePath => AppPaths.TestApp; + + protected override AppLaunchOptions DefaultOptions => new() { ShowPointer = true, MainWindowTimeout = TimeSpan.FromSeconds(7) }; + + public string Title => MainWindow.Name; +} diff --git a/tests/WinUia.UiTests/WinUia.UiTests.csproj b/tests/WinUia.UiTests/WinUia.UiTests.csproj new file mode 100644 index 0000000..248afd4 --- /dev/null +++ b/tests/WinUia.UiTests/WinUia.UiTests.csproj @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/tests/WinUia.UnitTests/AppLaunchOptionsTests.cs b/tests/WinUia.UnitTests/AppLaunchOptionsTests.cs new file mode 100644 index 0000000..98050d4 --- /dev/null +++ b/tests/WinUia.UnitTests/AppLaunchOptionsTests.cs @@ -0,0 +1,28 @@ +namespace WinUia.UnitTests; + +public class AppLaunchOptionsTests +{ + [Test] + public void Options_override_defaults_property_by_property() + { + var defaults = new AppLaunchOptions { Arguments = "--default", ShowPointer = true, MainWindowTimeout = TimeSpan.FromSeconds(7) }; + + var effective = defaults.OverriddenBy(new AppLaunchOptions { ShowPointer = false, WorkingDirectory = @"C:\work" }); + + Assert.That(effective, Is.EqualTo(new AppLaunchOptions + { + Arguments = "--default", + WorkingDirectory = @"C:\work", + ShowPointer = false, + MainWindowTimeout = TimeSpan.FromSeconds(7), + })); + } + + [Test] + public void No_options_keep_the_defaults() + { + var defaults = new AppLaunchOptions { ShowPointer = true }; + + Assert.That(defaults.OverriddenBy(null), Is.SameAs(defaults)); + } +} diff --git a/tests/WinUia.UnitTests/AppTests.cs b/tests/WinUia.UnitTests/AppTests.cs index 2e67ce2..2979f34 100644 --- a/tests/WinUia.UnitTests/AppTests.cs +++ b/tests/WinUia.UnitTests/AppTests.cs @@ -1,111 +1,35 @@ -using System.Diagnostics; -using WinUia.Core.Exceptions; -using WinUia.Testing.Shared; - namespace WinUia.UnitTests; -/// -/// Behaviour of itself: main window, Find timeouts, closing and ownership, against the empty test app. -/// Finding controls and windows by content is tested in the WinForms example. -/// -[UiTest] -public sealed class AppTests +public class AppTests { - private App _app = null!; - - [SetUp] - public void LaunchApp() => _app = App.Launch(AppPaths.TestApp); - - [TearDown] - public void CloseApp() => _app.Dispose(); - - [Test] - public void Launch_finds_the_main_window() - { - using (Assert.EnterMultipleScope()) - { - Assert.That(_app.MainWindow.Name, Is.EqualTo("WinUia Test App")); - Assert.That(_app.MainWindow.ProcessId, Is.EqualTo(_app.Process.Id)); - } - } - - [Test] - public void Find_of_a_missing_element_throws_after_the_timeout_and_TryFind_returns_null() - { - _ = _app.MainWindow; // The first Find also waits for the window to appear; keep that out of the timing. - - var stopwatch = Stopwatch.StartNew(); - Assert.Throws(() => _app.Find("doesNotExist", TimeSpan.FromMilliseconds(300))); - Assert.That(stopwatch.ElapsedMilliseconds, Is.InRange(290, 5000)); - - Assert.That(_app.TryFind("doesNotExist", TimeSpan.FromMilliseconds(100)), Is.Null); - } - [Test] - public void Close_ends_the_process() + public void Launch_without_a_path_needs_a_page_object_that_declares_its_executable() { - _app.Close(); + var ex = Assert.Throws(() => App.Launch()); - Assert.That(_app.Process.HasExited, Is.True); + Assert.That(ex.Message, Does.Contain("ExecutablePath")); } [Test] - public void Dispose_closes_a_launched_app_but_not_an_attached_one() + public void LaunchPackaged_rejects_options_Windows_decides_for_packaged_apps() { - using (var attached = App.Attach(_app.Process.Id)) - { - Assert.That(attached.MainWindow.Name, Is.EqualTo("WinUia Test App")); - } - - Assert.That(_app.Process.HasExited, Is.False); - - var pid = _app.Process.Id; - _app.Dispose(); - Assert.Throws(() => Process.GetProcessById(pid)); - } - - [Test] - public void As_hands_the_app_over_to_a_derived_page_object() - { - var original = _app; - var derived = original.As(); - _app = derived; // TearDown disposes the instance that owns the app now. - - original.Dispose(); - using (Assert.EnterMultipleScope()) { - Assert.That(derived.Process.HasExited, Is.False, "disposing the handed-over original leaves the app running"); - Assert.That(derived.Title, Is.EqualTo("WinUia Test App")); - Assert.Throws(() => original.As()); + Assert.Throws(() => + App.LaunchPackaged("Any.App_0000000000000!App", new AppLaunchOptions { WorkingDirectory = @"C:\" })); + Assert.Throws(() => + App.LaunchPackaged("Any.App_0000000000000!App", + new AppLaunchOptions { Environment = new Dictionary { ["A"] = "1" } })); } - - var pid = derived.Process.Id; - derived.Dispose(); - Assert.Throws(() => Process.GetProcessById(pid)); - } - - [Test] - public void As_twice_on_the_same_app_throws() - { - var derived = _app.As(); - var original = _app; - _app = derived; - - Assert.Throws(() => original.As()); } [Test] public void A_derived_app_created_directly_is_not_connected() { - using var unconnected = new DerivedApp(); - - Assert.Throws(() => _ = unconnected.Process); - } - - [Test] - public void Attaching_to_a_missing_process_throws_an_AppProcessException() - { - Assert.Throws(() => App.Attach("WinUia.No.Such.Process")); + Assert.Throws(() => + { + using var unconnected = new UnconnectedApp(); + _ = unconnected.Process; + }); } } diff --git a/tests/WinUia.UnitTests/UnconnectedApp.cs b/tests/WinUia.UnitTests/UnconnectedApp.cs new file mode 100644 index 0000000..71f192a --- /dev/null +++ b/tests/WinUia.UnitTests/UnconnectedApp.cs @@ -0,0 +1,4 @@ +namespace WinUia.UnitTests; + +/// A derived app created directly instead of through As<TApp>(), so it is connected to nothing. +public sealed class UnconnectedApp : App; diff --git a/tests/WinUia.UnitTests/WinUia.UnitTests.csproj b/tests/WinUia.UnitTests/WinUia.UnitTests.csproj index ba672b0..3ff6d18 100644 --- a/tests/WinUia.UnitTests/WinUia.UnitTests.csproj +++ b/tests/WinUia.UnitTests/WinUia.UnitTests.csproj @@ -1,9 +1,8 @@ + - -