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().ToArray();
- var reversed = old.Reverse().Select(b => Recreate(b, b.Text)).ToArray();
+ var reversed = old.Reverse().Select(b => Recreate(b, b.Text)).ToArray();
pnlItems.SuspendLayout();
foreach (var button in old)
@@ -61,6 +62,7 @@ private void btnReverse_Click(object? sender, EventArgs e)
{
Name = original.Name,
Text = text,
+ Cursor = original.Cursor,
Location = original.Location,
Size = original.Size,
UseVisualStyleBackColor = true,
diff --git a/src/Directory.Build.props b/src/Directory.Build.props
index fe96d97..5cbfcb6 100644
--- a/src/Directory.Build.props
+++ b/src/Directory.Build.props
@@ -7,8 +7,33 @@
true
+
+
+ Buning Software
+ Buning Software
+ Copyright © Buning Software
+ https://github.com/BuningSoftware/win-uia
+ https://github.com/BuningSoftware/win-uia
+ git
+ ui-automation;uia;uia3;windows;automation;testing;ui-testing;winforms;wpf;winui
+ README.md
+
+ true
+ snupkg
+
+
+
+
+
+
+
-
+
+
+
diff --git a/src/WinUia.Core/AutomationContext.cs b/src/WinUia.Core/AutomationContext.cs
index 8d42b8e..5a273e5 100644
--- a/src/WinUia.Core/AutomationContext.cs
+++ b/src/WinUia.Core/AutomationContext.cs
@@ -24,6 +24,7 @@ public AutomationContext()
// per-monitor aware as well covers UIA builds that only honour process awareness.
PhysicalDpi.MakeProcessAware();
+ // ReSharper disable once SuspiciousTypeConversion.Global (a COM coclass: the object behind it implements the interface)
_automation = (IUIAutomation)new CUIAutomation8();
}
@@ -39,6 +40,26 @@ public AutomationContext()
/// How long re-resolving a stale element waits for it to reappear. Default 1 second.
public TimeSpan StaleRefreshTimeout { get; set; } = TimeSpan.FromSeconds(1);
+ ///
+ /// Whether interactions first move the mouse cursor to the element, so you can follow on screen (or in a recording)
+ /// where each click and text entry happens. Interactions still go through UIA patterns as usual; the move only
+ /// shows the target. Off by default: each move takes , and hovering can show
+ /// tooltips or hover effects. Starts on when the environment variable WINUIA_SHOW_POINTER is 1 or
+ /// true , so a CI job can switch it on without code changes.
+ ///
+ public bool ShowPointer { get; set; } = Environment.GetEnvironmentVariable("WINUIA_SHOW_POINTER") is { } value
+ && (value == "1" || value.Equals("true", StringComparison.OrdinalIgnoreCase));
+
+ /// How long the cursor takes to move to an element when is on. Default 300 ms.
+ public TimeSpan PointerMoveDuration { get; set; } = TimeSpan.FromMilliseconds(300);
+
+ ///
+ /// The mouse and keyboard every element of this context uses for physical input (PhysicalClick , typing when
+ /// a control has no Value pattern, ). Default the real one, ;
+ /// a test can substitute its own, for example one that records what would be sent.
+ ///
+ public IInputSimulator Input { get; set; } = new Win32InputSimulator();
+
internal IUIAutomation Automation => _automation ?? throw new ObjectDisposedException(nameof(AutomationContext));
/// The desktop (root) element.
diff --git a/src/WinUia.Core/ComExceptionMapper.cs b/src/WinUia.Core/ComExceptionMapper.cs
index fcd62da..d382af5 100644
--- a/src/WinUia.Core/ComExceptionMapper.cs
+++ b/src/WinUia.Core/ComExceptionMapper.cs
@@ -1,6 +1,6 @@
using System.Runtime.InteropServices;
using WinUia.Core.Exceptions;
-using static WinUia.Core.Exceptions.HResults;
+using static WinUia.Core.Interop.HResults;
namespace WinUia.Core;
diff --git a/src/WinUia.Core/Element.Interaction.cs b/src/WinUia.Core/Element.Interaction.cs
index ae76bdc..a654779 100644
--- a/src/WinUia.Core/Element.Interaction.cs
+++ b/src/WinUia.Core/Element.Interaction.cs
@@ -1,4 +1,5 @@
using WinUia.Core.Exceptions;
+using WinUia.Core.Interop;
using WinUia.Input;
using WinUia.Core.Patterns;
@@ -33,6 +34,8 @@ public sealed partial class Element
///
/// Clicks the element through the first pattern that means "click" for it: Invoke, Toggle, SelectionItem,
/// then ExpandCollapse. Falls back to a physical mouse click ( ).
+ /// With on, the cursor moves to the element first, as it does for the
+ /// other interactions.
///
/// Native Win32 and WinForms buttons are clicked by posting BM_CLICK to their window instead of through
/// the Invoke pattern. Their Invoke runs the click handler inside the UIA call, so a handler that shows a modal
@@ -44,6 +47,7 @@ public sealed partial class Element
public void Click()
{
EnsureEnabled();
+ MovePointerHere();
if (InvokePattern.IsSupported)
{
@@ -70,7 +74,8 @@ private bool TryPostNativeButtonClick()
if (hwnd == 0 || !ClassName.Contains("BUTTON", StringComparison.OrdinalIgnoreCase))
return false;
- return NativeButton.PostClick(hwnd);
+ // Posted, not sent: the click runs from the application's own message loop (see Click).
+ return User32.PostMessage(hwnd, User32.BM_CLICK, 0, 0);
}
/// Scrolls the element into view if possible and clicks its clickable point with the mouse.
@@ -82,7 +87,9 @@ public void PhysicalClick(MouseButton button = MouseButton.Left)
ScrollItemPattern.ScrollIntoView();
var point = GetClickablePoint();
- Win32InputSimulator.ClickAt(point.X, point.Y, button);
+ if (Context.ShowPointer)
+ Context.Input.MoveTo(point.X, point.Y, Context.PointerMoveDuration); // Instead of jumping there.
+ Context.Input.ClickAt(point.X, point.Y, button);
}
///
@@ -109,6 +116,7 @@ public ScreenPoint GetClickablePoint()
public void SetValue(string value)
{
EnsureEnabled();
+ MovePointerHere();
if (ValuePattern.IsSupported && !ValuePattern.IsReadOnly)
{
@@ -117,11 +125,11 @@ public void SetValue(string value)
}
Focus();
- Win32InputSimulator.SendKeys(VirtualKey.Control, VirtualKey.A);
+ Context.Input.SendKeys(VirtualKey.Control, VirtualKey.A);
if (value.Length == 0)
- Win32InputSimulator.SendKeys(VirtualKey.Delete);
+ Context.Input.SendKeys(VirtualKey.Delete);
else
- Win32InputSimulator.SendText(value);
+ Context.Input.SendText(value);
}
/// The element's text: Text pattern, then Value pattern, then Name.
@@ -147,7 +155,34 @@ public string GetText()
public void Collapse() => WhenEnabled(ExpandCollapsePattern.Collapse);
/// Gives the element keyboard focus.
- public void Focus() => Do(e => e.SetFocus());
+ public void Focus()
+ {
+ MovePointerHere();
+ Do(e => e.SetFocus());
+ }
+
+ ///
+ /// With on, moves the cursor to the element's clickable point over
+ /// . Only shows where the interaction happens, so an element
+ /// without a clickable point (off screen, collapsed) is simply not pointed at.
+ ///
+ private void MovePointerHere()
+ {
+ if (!Context.ShowPointer)
+ return;
+
+ ScreenPoint point;
+ try
+ {
+ point = GetClickablePoint();
+ }
+ catch (UiaNoClickablePointException)
+ {
+ return;
+ }
+
+ Context.Input.MoveTo(point.X, point.Y, Context.PointerMoveDuration);
+ }
private void ToggleExpandCollapse()
{
@@ -166,6 +201,7 @@ private void EnsureEnabled()
private void WhenEnabled(Action action)
{
EnsureEnabled();
+ MovePointerHere();
action();
}
}
diff --git a/src/WinUia.Core/ElementPredicate.cs b/src/WinUia.Core/ElementPredicate.cs
index 52b61ec..66fb739 100644
--- a/src/WinUia.Core/ElementPredicate.cs
+++ b/src/WinUia.Core/ElementPredicate.cs
@@ -29,7 +29,7 @@ internal static class ElementPredicate
/// when the predicate cannot run inside UIA.
///
public static Expression> Canonicalize(Expression> predicate) =>
- FromBody(Fold(predicate.Body, () => Expression.Constant(true), Equality, Expression.AndAlso, Expression.OrElse, Expression.Not));
+ FromBody(Fold(predicate.Body, () => Expression.Constant(true), Equality, Expression.AndAlso, Expression.OrElse, Expression.Not));
/// A canonical predicate from a canonical .
private static Expression> FromBody(Expression body) => Expression.Lambda>(body, ElementParameter);
diff --git a/src/WinUia.Core/Exceptions/UiaElementNotEnabledException.cs b/src/WinUia.Core/Exceptions/UiaElementNotEnabledException.cs
index 29db86a..4477ebb 100644
--- a/src/WinUia.Core/Exceptions/UiaElementNotEnabledException.cs
+++ b/src/WinUia.Core/Exceptions/UiaElementNotEnabledException.cs
@@ -1,3 +1,5 @@
+using WinUia.Core.Interop;
+
namespace WinUia.Core.Exceptions;
/// The element is disabled and cannot be interacted with (UIA_E_ELEMENTNOTENABLED ).
diff --git a/src/WinUia.Core/Exceptions/UiaNoClickablePointException.cs b/src/WinUia.Core/Exceptions/UiaNoClickablePointException.cs
index 32520be..8b79472 100644
--- a/src/WinUia.Core/Exceptions/UiaNoClickablePointException.cs
+++ b/src/WinUia.Core/Exceptions/UiaNoClickablePointException.cs
@@ -1,3 +1,5 @@
+using WinUia.Core.Interop;
+
namespace WinUia.Core.Exceptions;
/// The element has no point that can be clicked (UIA_E_NOCLICKABLEPOINT ).
diff --git a/src/WinUia.Core/Exceptions/UiaPatternNotSupportedException.cs b/src/WinUia.Core/Exceptions/UiaPatternNotSupportedException.cs
index 6e7ab33..f8ff002 100644
--- a/src/WinUia.Core/Exceptions/UiaPatternNotSupportedException.cs
+++ b/src/WinUia.Core/Exceptions/UiaPatternNotSupportedException.cs
@@ -1,3 +1,5 @@
+using WinUia.Core.Interop;
+
namespace WinUia.Core.Exceptions;
/// The element does not support the requested control pattern (UIA_E_NOTSUPPORTED ).
diff --git a/src/WinUia.Core/Exceptions/UiaStaleElementException.cs b/src/WinUia.Core/Exceptions/UiaStaleElementException.cs
index 3569fa8..c48986d 100644
--- a/src/WinUia.Core/Exceptions/UiaStaleElementException.cs
+++ b/src/WinUia.Core/Exceptions/UiaStaleElementException.cs
@@ -1,3 +1,5 @@
+using WinUia.Core.Interop;
+
namespace WinUia.Core.Exceptions;
/// The element is no longer available, typically because its UI was re-rendered or closed (UIA_E_ELEMENTNOTAVAILABLE ).
diff --git a/src/WinUia.Core/Exceptions/UiaTimeoutException.cs b/src/WinUia.Core/Exceptions/UiaTimeoutException.cs
index dac2282..ead3d75 100644
--- a/src/WinUia.Core/Exceptions/UiaTimeoutException.cs
+++ b/src/WinUia.Core/Exceptions/UiaTimeoutException.cs
@@ -1,3 +1,5 @@
+using WinUia.Core.Interop;
+
namespace WinUia.Core.Exceptions;
/// A UI Automation call or wait timed out (UIA_E_TIMEOUT ).
diff --git a/src/WinUia.Core/Exceptions/HResults.cs b/src/WinUia.Core/Interop/HResults.cs
similarity index 95%
rename from src/WinUia.Core/Exceptions/HResults.cs
rename to src/WinUia.Core/Interop/HResults.cs
index eafd68d..309ba23 100644
--- a/src/WinUia.Core/Exceptions/HResults.cs
+++ b/src/WinUia.Core/Interop/HResults.cs
@@ -1,4 +1,4 @@
-namespace WinUia.Core.Exceptions;
+namespace WinUia.Core.Interop;
/// The HRESULTs WinUia recognises: UIA's own errors (UIAutomationCoreApi.h) and the COM/RPC errors of a vanished process.
internal static class HResults
diff --git a/src/WinUia.Core/Interop/IUIAutomationElement.cs b/src/WinUia.Core/Interop/IUIAutomationElement.cs
index 8afb1b9..e4591ab 100644
--- a/src/WinUia.Core/Interop/IUIAutomationElement.cs
+++ b/src/WinUia.Core/Interop/IUIAutomationElement.cs
@@ -8,7 +8,7 @@ internal interface IUIAutomationElement
{
void SetFocus();
[return: MarshalAs(UnmanagedType.SafeArray, SafeArraySubType = VarEnum.VT_I4)]
- int[] GetRuntimeId();
+ int[]? GetRuntimeId();
IUIAutomationElement? FindFirst(TreeScope scope, IUIAutomationCondition condition);
IUIAutomationElementArray FindAll(TreeScope scope, IUIAutomationCondition condition);
void Reserved_FindFirstBuildCache();
@@ -28,27 +28,27 @@ internal interface IUIAutomationElement
void Reserved_GetCachedChildren();
int CurrentProcessId { get; }
int CurrentControlType { get; }
- string CurrentLocalizedControlType { [return: MarshalAs(UnmanagedType.BStr)] get; }
- string CurrentName { [return: MarshalAs(UnmanagedType.BStr)] get; }
- string CurrentAcceleratorKey { [return: MarshalAs(UnmanagedType.BStr)] get; }
- string CurrentAccessKey { [return: MarshalAs(UnmanagedType.BStr)] get; }
+ string? CurrentLocalizedControlType { [return: MarshalAs(UnmanagedType.BStr)] get; }
+ string? CurrentName { [return: MarshalAs(UnmanagedType.BStr)] get; }
+ string? CurrentAcceleratorKey { [return: MarshalAs(UnmanagedType.BStr)] get; }
+ string? CurrentAccessKey { [return: MarshalAs(UnmanagedType.BStr)] get; }
bool CurrentHasKeyboardFocus { [return: MarshalAs(UnmanagedType.Bool)] get; }
bool CurrentIsKeyboardFocusable { [return: MarshalAs(UnmanagedType.Bool)] get; }
bool CurrentIsEnabled { [return: MarshalAs(UnmanagedType.Bool)] get; }
- string CurrentAutomationId { [return: MarshalAs(UnmanagedType.BStr)] get; }
- string CurrentClassName { [return: MarshalAs(UnmanagedType.BStr)] get; }
- string CurrentHelpText { [return: MarshalAs(UnmanagedType.BStr)] get; }
+ string? CurrentAutomationId { [return: MarshalAs(UnmanagedType.BStr)] get; }
+ string? CurrentClassName { [return: MarshalAs(UnmanagedType.BStr)] get; }
+ string? CurrentHelpText { [return: MarshalAs(UnmanagedType.BStr)] get; }
int CurrentCulture { get; }
bool CurrentIsControlElement { [return: MarshalAs(UnmanagedType.Bool)] get; }
bool CurrentIsContentElement { [return: MarshalAs(UnmanagedType.Bool)] get; }
bool CurrentIsPassword { [return: MarshalAs(UnmanagedType.Bool)] get; }
nint CurrentNativeWindowHandle { get; }
- string CurrentItemType { [return: MarshalAs(UnmanagedType.BStr)] get; }
+ string? CurrentItemType { [return: MarshalAs(UnmanagedType.BStr)] get; }
bool CurrentIsOffscreen { [return: MarshalAs(UnmanagedType.Bool)] get; }
int CurrentOrientation { get; }
- string CurrentFrameworkId { [return: MarshalAs(UnmanagedType.BStr)] get; }
+ string? CurrentFrameworkId { [return: MarshalAs(UnmanagedType.BStr)] get; }
bool CurrentIsRequiredForForm { [return: MarshalAs(UnmanagedType.Bool)] get; }
- string CurrentItemStatus { [return: MarshalAs(UnmanagedType.BStr)] get; }
+ string? CurrentItemStatus { [return: MarshalAs(UnmanagedType.BStr)] get; }
tagRECT CurrentBoundingRectangle { get; }
void Reserved_get_CurrentLabeledBy();
void Reserved_get_CurrentAriaRole();
diff --git a/src/WinUia.Core/Interop/IUIAutomationTextRange.cs b/src/WinUia.Core/Interop/IUIAutomationTextRange.cs
index f0c9b25..5bee247 100644
--- a/src/WinUia.Core/Interop/IUIAutomationTextRange.cs
+++ b/src/WinUia.Core/Interop/IUIAutomationTextRange.cs
@@ -16,5 +16,5 @@ internal interface IUIAutomationTextRange
void Reserved_GetBoundingRectangles();
void Reserved_GetEnclosingElement();
[return: MarshalAs(UnmanagedType.BStr)]
- string GetText(int maxLength);
+ string? GetText(int maxLength);
}
diff --git a/src/WinUia.Core/Interop/IUIAutomationValuePattern.cs b/src/WinUia.Core/Interop/IUIAutomationValuePattern.cs
index 17cb741..b15faaf 100644
--- a/src/WinUia.Core/Interop/IUIAutomationValuePattern.cs
+++ b/src/WinUia.Core/Interop/IUIAutomationValuePattern.cs
@@ -7,6 +7,6 @@ namespace WinUia.Core.Interop;
internal interface IUIAutomationValuePattern
{
void SetValue([MarshalAs(UnmanagedType.BStr)] string val);
- string CurrentValue { [return: MarshalAs(UnmanagedType.BStr)] get; }
+ string? CurrentValue { [return: MarshalAs(UnmanagedType.BStr)] get; }
bool CurrentIsReadOnly { [return: MarshalAs(UnmanagedType.Bool)] get; }
}
diff --git a/src/WinUia.Core/Interop/User32.cs b/src/WinUia.Core/Interop/User32.cs
new file mode 100644
index 0000000..66a4e25
--- /dev/null
+++ b/src/WinUia.Core/Interop/User32.cs
@@ -0,0 +1,13 @@
+using System.Runtime.InteropServices;
+
+namespace WinUia.Core.Interop;
+
+/// The user32 calls Core makes itself: posting a click to a native button (see Element.Click ).
+internal static class User32
+{
+ public const uint BM_CLICK = 0x00F5;
+
+ [DllImport("user32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ public static extern bool PostMessage(nint hWnd, uint msg, nint wParam, nint lParam);
+}
diff --git a/src/WinUia.Core/WinUia.Core.csproj b/src/WinUia.Core/WinUia.Core.csproj
index 88640fa..1cdce4a 100644
--- a/src/WinUia.Core/WinUia.Core.csproj
+++ b/src/WinUia.Core/WinUia.Core.csproj
@@ -6,7 +6,7 @@
-
+
diff --git a/src/WinUia.Input/IInputSimulator.cs b/src/WinUia.Input/IInputSimulator.cs
new file mode 100644
index 0000000..a540310
--- /dev/null
+++ b/src/WinUia.Input/IInputSimulator.cs
@@ -0,0 +1,27 @@
+namespace WinUia.Input;
+
+///
+/// Physical mouse and keyboard input, in physical screen pixels. WinUia sends all of its mouse and keyboard input
+/// through the of the AutomationContext , so a test can substitute its own (for
+/// example one that records what would be sent). is the real one.
+///
+public interface IInputSimulator
+{
+ /// The cursor position in physical screen pixels.
+ (int X, int Y) GetCursorPosition();
+
+ /// Moves the cursor to ( , ) at once, and makes sure it got there.
+ void MoveTo(int x, int y);
+
+ /// Moves the cursor to ( , ) over , so the movement can be followed.
+ void MoveTo(int x, int y, TimeSpan duration);
+
+ /// Moves the cursor to ( , ) and clicks.
+ void ClickAt(int x, int y, MouseButton button = MouseButton.Left);
+
+ /// Types into the focused control.
+ void SendText(string text);
+
+ /// Presses the keys in order and releases them in reverse order, for example Control+A.
+ void SendKeys(params VirtualKey[] keys);
+}
diff --git a/src/WinUia.Input/Interop/NativeMethods.cs b/src/WinUia.Input/Interop/NativeMethods.cs
index 15ba905..00a652b 100644
--- a/src/WinUia.Input/Interop/NativeMethods.cs
+++ b/src/WinUia.Input/Interop/NativeMethods.cs
@@ -22,9 +22,13 @@ internal static class NativeMethods
[DllImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
- public static extern bool PostMessage(nint hWnd, uint msg, nint wParam, nint lParam);
+ public static extern bool GetCursorPos(out POINT lpPoint);
- public const uint BM_CLICK = 0x00F5;
+ [DllImport("user32.dll")]
+ public static extern nint GetForegroundWindow();
+
+ [DllImport("user32.dll")]
+ public static extern uint GetWindowThreadProcessId(nint hWnd, out uint processId);
public const int SM_XVIRTUALSCREEN = 76;
public const int SM_YVIRTUALSCREEN = 77;
diff --git a/src/WinUia.Input/Interop/POINT.cs b/src/WinUia.Input/Interop/POINT.cs
new file mode 100644
index 0000000..e29ade9
--- /dev/null
+++ b/src/WinUia.Input/Interop/POINT.cs
@@ -0,0 +1,10 @@
+using System.Runtime.InteropServices;
+
+namespace WinUia.Input.Interop;
+
+[StructLayout(LayoutKind.Sequential)]
+internal struct POINT
+{
+ public int x;
+ public int y;
+}
diff --git a/src/WinUia.Input/MouseButton.cs b/src/WinUia.Input/MouseButton.cs
index cf67182..76f0c73 100644
--- a/src/WinUia.Input/MouseButton.cs
+++ b/src/WinUia.Input/MouseButton.cs
@@ -1,6 +1,6 @@
namespace WinUia.Input;
-/// Mouse buttons for .
+/// Mouse buttons for .
public enum MouseButton
{
/// The primary button.
diff --git a/src/WinUia.Input/NativeButton.cs b/src/WinUia.Input/NativeButton.cs
deleted file mode 100644
index 7169c6d..0000000
--- a/src/WinUia.Input/NativeButton.cs
+++ /dev/null
@@ -1,13 +0,0 @@
-using WinUia.Input.Interop;
-
-namespace WinUia.Input;
-
-/// Clicks native Win32 push buttons ("BUTTON" window class) through their window's message queue.
-public static class NativeButton
-{
- ///
- /// Posts BM_CLICK to the button, so the click runs from the application's own message loop rather
- /// than inside the caller's call. Returns false when the message could not be posted.
- ///
- public static bool PostClick(nint hwnd) => NativeMethods.PostMessage(hwnd, NativeMethods.BM_CLICK, 0, 0);
-}
diff --git a/src/WinUia.Input/VirtualKey.cs b/src/WinUia.Input/VirtualKey.cs
index 87aa118..598c7f5 100644
--- a/src/WinUia.Input/VirtualKey.cs
+++ b/src/WinUia.Input/VirtualKey.cs
@@ -1,6 +1,6 @@
namespace WinUia.Input;
-/// Virtual-key codes for .
+/// Virtual-key codes for .
public enum VirtualKey : ushort
{
#pragma warning disable CS1591 // Standard Win32 virtual-key names.
diff --git a/src/WinUia.Input/Win32InputSimulator.cs b/src/WinUia.Input/Win32InputSimulator.cs
index 5281a3c..3f492eb 100644
--- a/src/WinUia.Input/Win32InputSimulator.cs
+++ b/src/WinUia.Input/Win32InputSimulator.cs
@@ -1,4 +1,5 @@
using System.ComponentModel;
+using System.Diagnostics;
using System.Runtime.InteropServices;
using WinUia.Input.Interop;
using static WinUia.Input.Interop.NativeMethods;
@@ -7,17 +8,84 @@ namespace WinUia.Input;
///
/// Physical mouse and keyboard input through SendInput . Coordinates are physical screen pixels.
-/// Physical input needs an interactive desktop, goes to whatever window is under the cursor or focused, and
-/// cannot reach an elevated application unless the calling process is elevated too (UIPI).
+/// Physical input needs an interactive desktop, goes to whatever window is under the cursor or focused, and is
+/// silently dropped by Windows while the foreground window belongs to a process with higher rights (elevated, or a
+/// system service), unless this process has them too (UIPI).
///
-public static class Win32InputSimulator
+public sealed class Win32InputSimulator : IInputSimulator
{
- /// Moves the cursor to ( , ).
- public static void MoveTo(int x, int y) => PhysicalDpi.Run(() =>
- Send([MouseInput(MOUSEEVENTF_MOVE | MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_VIRTUALDESK, x, y)]));
+ ///
+ /// Pause between the positions of a smooth move: smooth to the eye and in recordings. Default 10 ms (in practice
+ /// Windows' timer makes it about 15 ms).
+ ///
+ public TimeSpan MoveStepInterval { get; init; } = TimeSpan.FromMilliseconds(10);
- /// Moves the cursor to ( , ) and clicks.
- public static void ClickAt(int x, int y, MouseButton button = MouseButton.Left)
+ /// How often a move is sent before gives up. Default 3.
+ public int MoveAttempts { get; init; } = 3;
+
+ /// How long each attempt waits for the cursor to arrive. Default 100 ms.
+ public TimeSpan MoveArrivalTimeout { get; init; } = TimeSpan.FromMilliseconds(100);
+
+ ///
+ /// Moves the cursor to ( , ) and makes sure it got there: a click after a
+ /// dropped move would land wherever the cursor happens to be, so the move is checked and sent again. Throws
+ /// when the cursor still is not there, naming the foreground window's
+ /// process, whose rights are the usual reason (see ).
+ ///
+ public void MoveTo(int x, int y)
+ {
+ for (var attempt = 1; ; attempt++)
+ {
+ SendMove(x, y);
+ if (CursorArrives(x, y))
+ return;
+
+ if (attempt >= MoveAttempts)
+ {
+ throw new InvalidOperationException(
+ $"The cursor could not be moved to ({x}, {y}); it is at {GetCursorPosition()}. Windows drops " +
+ $"simulated input while the foreground window belongs to an elevated process or a system service; " +
+ $"the foreground window now belongs to '{ForegroundProcessName()}'. Otherwise the point may be off " +
+ "every screen, or someone is moving the mouse at the same time.");
+ }
+ }
+ }
+
+ ///
+ /// Moves the cursor from where it is to ( , ) over
+ /// , along a straight line that starts and ends slowly, so the movement can be followed
+ /// on screen. Ends exactly on the target; a zero duration, or a cursor already there, moves at once.
+ ///
+ public void MoveTo(int x, int y, TimeSpan duration)
+ {
+ var (startX, startY) = GetCursorPosition();
+ if (duration > TimeSpan.Zero && (startX, startY) != (x, y))
+ {
+ // Positions follow the clock rather than a step count: Thread.Sleep is coarse (about 15 ms), so counting
+ // steps would stretch the move well past its duration.
+ var elapsed = Stopwatch.StartNew();
+ for (var t = 0.0; t < 1; t = elapsed.Elapsed / duration)
+ {
+ var progress = EaseInOut(t);
+ // Intermediate positions only animate the move; just the final one is checked (MoveTo below).
+ SendMove(startX + (int)Math.Round((x - startX) * progress), startY + (int)Math.Round((y - startY) * progress));
+ Thread.Sleep(MoveStepInterval);
+ }
+ }
+
+ MoveTo(x, y);
+ }
+
+ ///
+ public (int X, int Y) GetCursorPosition() => PhysicalDpi.Run(() =>
+ {
+ if (!GetCursorPos(out var point))
+ throw new Win32Exception(Marshal.GetLastWin32Error(), "GetCursorPos failed; is the desktop locked?");
+ return (point.x, point.y);
+ });
+
+ ///
+ public void ClickAt(int x, int y, MouseButton button = MouseButton.Left)
{
MoveTo(x, y);
Thread.Sleep(20);
@@ -28,8 +96,8 @@ public static void ClickAt(int x, int y, MouseButton button = MouseButton.Left)
PhysicalDpi.Run(() => Send([MouseInput(down, 0, 0, absolute: false), MouseInput(up, 0, 0, absolute: false)]));
}
- /// Types into the focused control as Unicode characters.
- public static void SendText(string text)
+ /// Types into the focused control as Unicode characters; "\r\n", "\r" and "\n" are Enter.
+ public void SendText(string text)
{
var inputs = new List (text.Length * 2);
for (var i = 0; i < text.Length; i++)
@@ -57,7 +125,7 @@ public static void SendText(string text)
/// Presses the keys in order and releases them in reverse order: one key (SendKeys(VirtualKey.Enter) ) or
/// a combination (SendKeys(VirtualKey.Control, VirtualKey.A) ).
///
- public static void SendKeys(params VirtualKey[] keys)
+ public void SendKeys(params VirtualKey[] keys)
{
var inputs = new List (keys.Length * 2);
inputs.AddRange(keys.Select(k => KeyInput((ushort)k, 0, 0)));
@@ -65,23 +133,61 @@ public static void SendKeys(params VirtualKey[] keys)
Send([.. inputs]);
}
+ // SendInput queues the move; the cursor follows once Windows has processed it, usually at once.
+ private bool CursorArrives(int x, int y)
+ {
+ var waited = Stopwatch.StartNew();
+ while (GetCursorPosition() != (x, y))
+ {
+ if (waited.Elapsed >= MoveArrivalTimeout)
+ return false;
+ Thread.Sleep(5);
+ }
+
+ return true;
+ }
+
+ private static string ForegroundProcessName()
+ {
+ try
+ {
+ GetWindowThreadProcessId(GetForegroundWindow(), out var processId);
+ using var process = Process.GetProcessById((int)processId);
+ return process.ProcessName;
+ }
+ catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
+ {
+ return "unknown"; // No foreground window, or its process just exited.
+ }
+ }
+
+ private static void SendMove(int x, int y) => PhysicalDpi.Run(() =>
+ Send([MouseInput(MOUSEEVENTF_MOVE | MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_VIRTUALDESK, x, y)]));
+
+ // Quadratic ease-in-out: slow start, fast middle, slow arrival. 0 maps to 0 and 1 to 1.
+ private static double EaseInOut(double t) => t < 0.5 ? 2 * t * t : 1 - Math.Pow(-2 * t + 2, 2) / 2;
+
private static INPUT MouseInput(uint flags, int x, int y, bool absolute = true)
{
var mi = new MOUSEINPUT { dwFlags = flags };
if (absolute)
{
- // Absolute coordinates are normalised to 0..65535 across the virtual desktop.
+ // Absolute coordinates are normalised to 0..65535 across the virtual desktop. Windows maps them back with
+ // pixel = floor(value * size / 65536), so rounding up here lands on exactly the requested pixel.
var left = GetSystemMetrics(SM_XVIRTUALSCREEN);
var top = GetSystemMetrics(SM_YVIRTUALSCREEN);
- var width = Math.Max(GetSystemMetrics(SM_CXVIRTUALSCREEN), 2);
- var height = Math.Max(GetSystemMetrics(SM_CYVIRTUALSCREEN), 2);
- mi.dx = (int)Math.Round((x - left) * 65535.0 / (width - 1));
- mi.dy = (int)Math.Round((y - top) * 65535.0 / (height - 1));
+ var width = Math.Max(GetSystemMetrics(SM_CXVIRTUALSCREEN), 1);
+ var height = Math.Max(GetSystemMetrics(SM_CYVIRTUALSCREEN), 1);
+ mi.dx = Normalise(x - left, width);
+ mi.dy = Normalise(y - top, height);
}
return new INPUT { type = INPUT_MOUSE, u = new InputUnion { mi = mi } };
}
+ private static int Normalise(int offset, int size) =>
+ Math.Clamp((int)Math.Ceiling(offset * 65536.0 / size), 0, 65535);
+
private static INPUT KeyInput(ushort vk, ushort scan, uint flags) =>
new() { type = INPUT_KEYBOARD, u = new InputUnion { ki = new KEYBDINPUT { wVk = vk, wScan = scan, dwFlags = flags } } };
diff --git a/src/WinUia.NUnit/UiTestAttribute.cs b/src/WinUia.NUnit/UiTestAttribute.cs
index 1158e51..303a52f 100644
--- a/src/WinUia.NUnit/UiTestAttribute.cs
+++ b/src/WinUia.NUnit/UiTestAttribute.cs
@@ -16,12 +16,12 @@ namespace WinUia.NUnit;
/// Put it on a test class, a test method or the assembly ([assembly: UiTest] ).
///
/// [UiTest]
-/// public class MainFormTests
+/// public class MainPageTests
/// {
-/// private MainForm _mainForm = null!;
+/// private MainPage _page = null!;
///
-/// [SetUp] public void SetUp() => _mainForm = App.Launch(path).As<MainForm>();
-/// [TearDown] public void TearDown() => _mainForm.Dispose();
+/// [SetUp] public void SetUp() => _page = App.Launch<MainPage>();
+/// [TearDown] public void TearDown() => _page.Dispose();
/// }
///
///
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 @@
+
-
-