Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 63 additions & 36 deletions .github/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@
<p align="center">
A .NET library for automating Windows applications through Microsoft UI Automation.
<br />
<a href="https://github.com/BuningSoftware/win-uia/issues">Report Bug</a>
<a href="https://github.com/buning-software/win-uia/issues">Report Bug</a>
·
<a href="https://github.com/BuningSoftware/win-uia/issues">Request Feature</a>
<a href="https://github.com/buning-software/win-uia/issues">Request Feature</a>
</p>
</div>

Expand All @@ -32,6 +32,7 @@
<a href="#getting-started">Getting Started</a>
<ul>
<li><a href="#installation">Installation</a></li>
<li><a href="#building-from-source">Building from source</a></li>
</ul>
</li>
<li>
Expand Down Expand Up @@ -65,14 +66,21 @@ WinUia is a .NET library for inspecting and automating Windows applications thro
* [Microsoft UI Automation](https://learn.microsoft.com/windows/win32/winauto/entry-uiauto-win32)

## Getting Started
Setting up WinUia on your local machine is straightforward. Make sure the [.NET 10 SDK](https://dotnet.microsoft.com/download) is installed.
WinUia needs the [.NET 10 SDK](https://dotnet.microsoft.com/download) and Windows.

### Installation

```bash
dotnet add package WinUia
dotnet add package WinUia.NUnit # for NUnit UI tests
```

### Building from source

1. **Clone the repository:**

```bash
git clone https://github.com/BuningSoftware/win-uia.git
git clone https://github.com/buning-software/win-uia.git
cd win-uia
```

Expand All @@ -94,7 +102,6 @@ Setting up WinUia on your local machine is straightforward. Make sure the [.NET

```csharp
using WinUia;
using WinUia.Core;

using var app = App.Launch(@"C:\path\to\MyApp.exe"); // or App.LaunchPackaged("Publisher.App_hash!App"), App.Attach(pid)

Expand All @@ -113,47 +120,67 @@ 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; the generic factories
(`App.Launch<T>`, `App.LaunchPackaged<T>`, `App.Attach<T>`) 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`):
An application can have its own class that derives from `App` and says which executable it is and how it starts by
default, so tests launch it without repeating either; the generic factories (`App.Launch<T>`, `App.Attach<T>`) create it already connected. `AppLaunchOptions` sets the arguments, working directory, environment,
pointer and main-window timeout; options passed to a factory override those defaults one by one.

The app owns the process, so it is the one thing you dispose. What is on screen is described by page objects: plain
classes that read the app and own nothing, so they need no teardown. A page for part of the window, such as a tab, can
derive from the main window's page and so reach its controls too (see `examples/WinUia.Examples.Winforms.UiTests`):

```csharp
public sealed class MainPage : App
using WinUia;
using WinUia.Models; // AppLaunchOptions

public sealed class MyApp : 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?")); }
public class MainForm(MyApp app)
{
public Element Window => app.MainWindow;
public Element SaveButton => Window.FindByAutomationId("btnSave");
public SettingsTab OpenSettings() { Window.FindByAutomationId("tabSettings").Select(); return new SettingsTab(app); }
}

public class SettingsTab(MyApp app) : MainForm(app) // has SaveButton and OpenSettings() as well
{
public Element Content => Window.FindByAutomationId("lblSettings");
}

using var page = App.Launch<MainPage>(); // its executable and defaults
using var quiet = App.Launch<MainPage>(new AppLaunchOptions { ShowPointer = false }); // one default overridden
using var other = App.Launch<MainPage>(@"D:\builds\MyApp.exe"); // another executable
// Disposing a page closes its app.
using var app = App.Launch<MyApp>(); // its executable and defaults
using var quiet = App.Launch<MyApp>(options: new AppLaunchOptions { ShowPointer = false }); // one default overridden
using var other = App.Launch<MyApp>(@"D:\builds\MyApp.exe"); // another executable

var mainForm = new MainForm(app);
mainForm.SaveButton.Click();
var settings = mainForm.OpenSettings();
// Disposing the app closes it; the pages need nothing.
```

In NUnit tests, put the launch and the dispose in one base fixture so each test class only writes its own setup
(`examples/WinUia.Examples.Winforms.UiTests/Fixtures/WinFormsFixture.cs`).

### Project structure

WinUia is a modular monolith: one solution, one project per module, and one test project per module. Dependencies point one way, from the application layer down to the platform:

| Module | Layer | Contents | Depends on |
|---|---|---|---|
| `WinUia` | Application | `App` (launch, attach, find, close), `AppProcessException`; internal launchers (`WinUia.Launchers`) | `WinUia.Core` (public API only) |
| `WinUia.Core` | Automation | `AutomationContext`, `Element` (lambda searches such as `Find(e => e.Name == "OK")`, self-healing locators), `Poll`, `WinUia.Core.Patterns`, `WinUia.Core.Exceptions`, the UIA COM interop | `WinUia.Input` (public API only) |
| `WinUia.Input` | Platform | `IInputSimulator` and `Win32InputSimulator` (`SendInput`; each `AutomationContext` has one as `Input`, replaceable in tests), `PhysicalDpi` | — |
| `WinUia` | Application | `App` (launch, attach, find, close), `AppLaunchOptions`, `AppProcessException`; internal launchers | `WinUia.Core` (public API only) |
| `WinUia.Core` | Automation | `AutomationContext`, `Element` (lambda searches such as `Find(e => e.Name == "OK")`, self-healing locators), `WinUia.Patterns`, the `Uia*Exception` types, physical input (`WinUia.Input`: `IInputSimulator` and `Win32InputSimulator` via `SendInput`; each `AutomationContext` has one as `Input`, replaceable in tests), the UIA COM and Win32 interop | — |
| `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 (`IInputSimulator`, `PhysicalDpi`), never its P/Invoke or COM declarations.
* Each module's `Interop/` folder is private to that module. Other modules use what it offers (`IInputSimulator`), 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 test projects.
* Namespaces follow project and folder. One type per file.
* Folders group files by role (for example models, enumerations, exceptions, patterns, interop); a folder exists only when it has files, and test projects mirror the folders of what they test. Namespaces do not follow folders: public and internal types alike use `WinUia`, `WinUia.Patterns`, `WinUia.Input` and `WinUia.NUnit` (visibility is the `internal` keyword's job), except `Interop/`, which keeps its project's `.Interop` namespace as a fence, and `AppLaunchOptions`, which lives in `WinUia.Models`; tests follow project and folder. One type per file.
* Package versions are set once, in `Directory.Packages.props`.
* Every public member is listed in the package's `PublicAPI.Unshipped.txt` (moved to `PublicAPI.Shipped.txt` at release); the build fails on an unlisted change, so a public API change always shows in review.

Each module has up to three test projects under `tests/`, by what the tests need:

Expand All @@ -167,15 +194,15 @@ Every test in a `.UiTests` project gets `[UiTest]` from the build (category `UI`
`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`. Tests use NUnit.
`examples/WinUia.Examples.Winforms.UiTests`. 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
`WinUia.Examples.Winforms.UiTests` tests it with page objects, using only WinUia's public API and `WinUia.NUnit`, nothing
from `tests/`. Examples and tests are independent: neither references the other.

### Limitations

* **MTA only:** UI Automation must be called from an MTA thread. Creating an `AutomationContext` or `Automation` on an STA thread (for example a WinForms/WPF UI thread, or an `[STAThread]` `Main`) throws. Use `Task.Run` or a thread-pool thread.
* **MTA only:** UI Automation must be called from an MTA thread. Creating an `AutomationContext` (or launching an `App`) on an STA thread (for example a WinForms/WPF UI thread, or an `[STAThread]` `Main`) throws. Use `Task.Run` or a thread-pool thread.
* **Physical input needs a real desktop:** the mouse and keyboard fallbacks use `SendInput`, which does nothing on a locked workstation or a disconnected RDP session, and goes to whatever window is on top.
* **Elevation (UIPI):** a non-elevated process cannot send input to, and has limited UIA access to, an elevated app. Run the automation elevated when the target is.
* **DPI:** creating an `AutomationContext` makes the process per-monitor DPI aware (if it is not already) so UIA coordinates and `SendInput` both use physical pixels.
Expand Down Expand Up @@ -206,13 +233,13 @@ Distributed under the GNU Affero General Public License v3.0 License. See `LICEN

<!-- MARKDOWN LINKS & IMAGES -->
<!-- https://www.markdownguide.org/basic-syntax/#reference-style-links -->
[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
[contributors-shield]: https://img.shields.io/github/contributors/buning-software/win-uia.svg?style=for-the-badge
[contributors-url]: https://github.com/buning-software/win-uia/graphs/contributors
[forks-shield]: https://img.shields.io/github/forks/buning-software/win-uia.svg?style=for-the-badge
[forks-url]: https://github.com/buning-software/win-uia/network/members
[stars-shield]: https://img.shields.io/github/stars/buning-software/win-uia.svg?style=for-the-badge
[stars-url]: https://github.com/buning-software/win-uia/stargazers
[issues-shield]: https://img.shields.io/github/issues/buning-software/win-uia.svg?style=for-the-badge
[issues-url]: https://github.com/buning-software/win-uia/issues
[license-shield]: https://img.shields.io/github/license/buning-software/win-uia.svg?style=for-the-badge
[license-url]: https://github.com/buning-software/win-uia/blob/main/LICENSE
23 changes: 23 additions & 0 deletions .github/actions/setup/action.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
name: Setup
description: Installs the .NET SDK, loads the NuGet cache and restores the solution. Run after checkout.

runs:
using: composite
steps:
- name: Setup .NET SDK
uses: actions/setup-dotnet@v6
with:
dotnet-version: '10.0.x'

- name: Load nuget cache
uses: actions/cache@v6
with:
path: ~/.nuget/packages
key: ${{ runner.os }}-nuget-${{ hashFiles('**/Directory.Packages.props', '**/*.csproj', '**/Directory.Build.props') }}
restore-keys: |
${{ runner.os }}-nuget-

# dotnet test needs the restored project assets, even when the binaries come from the build job.
- name: Install dependencies
shell: bash
run: dotnet restore
59 changes: 8 additions & 51 deletions .github/workflows/build-and-test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,10 @@ on:
workflow_dispatch:
workflow_call: {}

# One run per pull request: a new commit cancels the run still in progress for the previous one.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

# WinUia is Windows-only (UI Automation COM, SendInput), so every job runs on Windows.
# build compiles once; unit-test and ui-test run in parallel on its output.
jobs:
build:
name: Build
Expand All @@ -20,21 +17,11 @@ jobs:
- name: Checkout
uses: actions/checkout@v7

- name: Setup .NET SDK
uses: actions/setup-dotnet@v6
with:
dotnet-version: '10.0.x'

- name: Load nuget cache
uses: actions/cache@v6
with:
path: ~/.nuget/packages
key: ${{ runner.os }}-nuget-${{ hashFiles('**/Directory.Packages.props', '**/*.csproj', '**/Directory.Build.props') }}
restore-keys: |
${{ runner.os }}-nuget-
- name: Setup
uses: ./.github/actions/setup

- name: Install dependencies
run: dotnet restore
- name: Check for unused usings
run: dotnet format style WinUia.slnx --diagnostics IDE0005 --severity info --verify-no-changes

- name: Build
run: dotnet build --no-restore --configuration Release
Expand All @@ -59,22 +46,8 @@ jobs:
- name: Checkout
uses: actions/checkout@v7

- name: Setup .NET SDK
uses: actions/setup-dotnet@v6
with:
dotnet-version: '10.0.x'

- name: Load nuget cache
uses: actions/cache@v6
with:
path: ~/.nuget/packages
key: ${{ runner.os }}-nuget-${{ hashFiles('**/Directory.Packages.props', '**/*.csproj', '**/Directory.Build.props') }}
restore-keys: |
${{ runner.os }}-nuget-

# dotnet test needs the restored project assets to find the test projects; the binaries come from build.
- name: Install dependencies
run: dotnet restore
- name: Setup
uses: ./.github/actions/setup

- name: Download build output
uses: actions/download-artifact@v8
Expand All @@ -86,31 +59,15 @@ jobs:

ui-test:
name: Run UI tests
# UI tests ([UiTest], category UI) launch apps and drive them through UI Automation and SendInput, so they need an
# interactive desktop session, which GitHub-hosted Windows runners have.
needs: build
runs-on: windows-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v7

- name: Setup .NET SDK
uses: actions/setup-dotnet@v6
with:
dotnet-version: '10.0.x'

- name: Load nuget cache
uses: actions/cache@v6
with:
path: ~/.nuget/packages
key: ${{ runner.os }}-nuget-${{ hashFiles('**/Directory.Packages.props', '**/*.csproj', '**/Directory.Build.props') }}
restore-keys: |
${{ runner.os }}-nuget-

# dotnet test needs the restored project assets to find the test projects; the binaries come from build.
- name: Install dependencies
run: dotnet restore
- name: Setup
uses: ./.github/actions/setup

- name: Download build output
uses: actions/download-artifact@v8
Expand Down
50 changes: 35 additions & 15 deletions .github/workflows/publish.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
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.
# nuget.org trusted publishing: it trusts this workflow (owner buning-software, repository win-uia, workflow
# publish.yaml, environment nuget) and exchanges its OIDC token for a short-lived API key; no API key is stored.
# The nuget environment's NUGET_USER secret is the nuget.org account name.
on:
release:
types: [published]
Expand All @@ -10,7 +11,6 @@ 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
Expand All @@ -20,29 +20,31 @@ jobs:
needs: test
runs-on: windows-latest
environment: nuget
permissions:
contents: read
packages: write
# For NuGet/login to request the OIDC token.
id-token: write
steps:
- name: Checkout
uses: actions/checkout@v7

- name: Setup .NET SDK
uses: actions/setup-dotnet@v6
with:
dotnet-version: '10.0.x'
- name: Setup
uses: ./.github/actions/setup

- name: Version from the release tag
id: version
shell: bash
env:
TAG: ${{ github.event.release.tag_name }}
run: |
version="${TAG#v}"
version="${TAG#[vV]}"
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
Expand All @@ -55,11 +57,29 @@ jobs:
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 GitHub Packages
shell: bash
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
for package in packages/*.nupkg; do
dotnet nuget push "$package" --api-key "$GITHUB_TOKEN" --no-symbols \
--source "https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json" --skip-duplicate
done

- name: Log in to nuget.org
id: nuget-login
uses: NuGet/login@v1
with:
user: ${{ secrets.NUGET_USER }}

# Bash expands the glob: dotnet nuget push on Windows does not and fails with "File does not exist".
- 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
env:
NUGET_API_KEY: ${{ steps.nuget-login.outputs.NUGET_API_KEY }}
run: |
for package in packages/*.nupkg; do
dotnet nuget push "$package" --api-key "$NUGET_API_KEY" \
--source https://api.nuget.org/v3/index.json --skip-duplicate
done
Loading
Loading