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
93 changes: 88 additions & 5 deletions .github/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@
<li><a href="#installation">Installation</a></li>
</ul>
</li>
<li>
<a href="#usage">Usage</a>
<ul>
<li><a href="#limitations">Limitations</a></li>
</ul>
</li>
<li><a href="#contributing">Contributing</a></li>
<li><a href="#license">License</a></li>
</ol>
Expand All @@ -47,14 +53,16 @@ WinUia is a .NET library for inspecting and automating Windows applications thro

### Features

* **Feature One:** Describe a core responsibility or benefit here.
* **Feature Two:** Highlight another modular component or capability.
* **Modern Architecture:** Built with SOLID principles and design patterns for maximum maintainability.
* **No dependencies:** talks to the native UIA3 COM API (`UIAutomationCore.dll`) through hand-written interop. No FlaUI, no interop packages.
* **Works across UI stacks:** WinForms, WPF, WinUI 3 (packaged and unpackaged), UWP and classic Win32 apps, x86 or x64.
* **Self-healing elements:** an element remembers how it was found and re-finds itself when a re-render makes it stale.
* **Waiting built in:** `Find` polls until the element appears (default 5 s), `TryFind` returns `null` instead of throwing.
* **Patterns first, mouse last:** `Click()` and `SetValue()` use UIA patterns (Invoke, Toggle, SelectionItem, ExpandCollapse, Value) and only fall back to `SendInput`.

### Built With

* [Framework/Language Name](https://example.com)
* [Library Name](https://example.com)
* [.NET 10](https://dotnet.microsoft.com/)
* [Microsoft UI Automation](https://learn.microsoft.com/windows/win32/winauto/entry-uiauto-win32)

## Getting Started
Setting up WinUia on your local machine is straightforward. Make sure the [.NET 10 SDK](https://dotnet.microsoft.com/download) is installed.
Expand All @@ -80,6 +88,81 @@ Setting up WinUia on your local machine is straightforward. Make sure the [.NET
dotnet test WinUia.slnx
```

The UI tests (`Category=UI`) launch an app (the empty `tests/WinUia.TestApp`, or `examples/WinUia.Examples.Winforms` for the examples) and need an interactive desktop. Run only the others with `dotnet test WinUia.slnx --filter "Category!=UI"`.

## Usage

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

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

app.Find("txtName").SetValue("Ada"); // by AutomationId, then by Name
app.Find("btnSave").Click();

var status = app.MainWindow.FindByAutomationId("lblStatus", TimeSpan.FromSeconds(10));
Console.WriteLine(status.Name);

var dialog = app.FindWindow("Save changes?"); // a dialog or other window of the app
dialog.Find(e => e.ControlType == ControlType.Button && e.Name == "Yes").Click();
// Disposing a launched app closes it (Window pattern first, then kill).
```

`Element`'s own methods (`Click`, `SetValue`, `GetText`, `Toggle`, `Select`, `Expand`, `Collapse`) are the everyday API:
they check the element is enabled and pick the right pattern. The pattern wrappers (`element.TogglePattern.State`,
`element.WindowPattern.Close()`, ...) are for reading pattern state and for the members those methods do not cover.

A page object for the whole application derives from `App` and lists its controls; `As<T>()` turns a launched or
attached app into it, FlaUI-style. Page objects for part of the UI are plain classes built on an `Element`
(see `examples/WinUia.Examples.Winforms.Tests`):

```csharp
public sealed class MainPage : App
{
public Element SaveButton => MainWindow.FindByAutomationId("btnSave");
public SaveDialog Save() { SaveButton.Click(); return new SaveDialog(FindWindow("Save changes?")); }
}

using var page = App.Launch(@"C:\path\to\MyApp.exe").As<MainPage>(); // disposing the page closes the app
```

### Project structure

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

| Module | Layer | Contents | Depends on |
|---|---|---|---|
| `WinUia` | Application | `App` (launch, attach, find, close), `AppProcessException`; internal launchers (`WinUia.Launchers`) | `WinUia.Core` (public API only) |
| `WinUia.Core` | Automation | `AutomationContext`, `Element` (lambda searches such as `Find(e => e.Name == "OK")`, self-healing locators), `Poll`, `WinUia.Core.Patterns`, `WinUia.Core.Exceptions`, the UIA COM interop | `WinUia.Input` (public API only) |
| `WinUia.Input` | Platform | `Win32InputSimulator` (`SendInput`), `PhysicalDpi`, `NativeButton` | — |
| `WinUia.NUnit` | Test integration | `[UiTest]`: one desktop per test, across test processes; `Eventually(...)` for asynchronous UI state | NUnit, `WinUia.Core` |

Module rules:

* Each module's `Interop/` folder is private to that module. Other modules use what it offers (`PhysicalDpi`, `NativeButton`), never its P/Invoke or COM declarations.
* Modules use each other's public API only, the same API an application built on WinUia gets.
* Internals are shared only through `InternalsVisibleTo`, and only with the module's own `<Module>.UnitTests` project.
* Namespaces follow project and folder. One type per file.
* Package versions are set once, in `Directory.Packages.props`.

Tests live next to their module as `tests/<Module>.UnitTests`. The ones that drive a real application launch
`tests/WinUia.TestApp`, an empty window, located through `tests/WinUia.Testing.Shared` (`AppPaths`). Tests of control
behaviour (patterns, searches, self-healing elements, dialogs, input) run against the WinForms example instead, in
`examples/WinUia.Examples.Winforms.Tests`. UI tests are marked `[UiTest]`
(category `UI`). Tests use NUnit.

`examples/` shows WinUia the way a user would use it: `WinUia.Examples.Winforms` is a WinForms app and
`WinUia.Examples.Winforms.Tests` tests it with page objects, using only WinUia's public API and `WinUia.NUnit`, nothing
from `tests/`. Examples and tests are independent: neither references the other.

### Limitations

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

<!-- CONTRIBUTING -->
## Contributing

Expand Down
114 changes: 101 additions & 13 deletions .github/workflows/build-and-test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,30 +5,31 @@ on:
workflow_dispatch:
workflow_call: {}

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

# WinUia is Windows-only (UI Automation COM, SendInput), so every job runs on Windows.
# build compiles once; unit-test and ui-test run in parallel on its output.
jobs:
build:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
name: Build
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v7

- run: echo ${{matrix.os}}

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

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

Expand All @@ -38,5 +39,92 @@ jobs:
- name: Build
run: dotnet build --no-restore --configuration Release

- name: Test
run: dotnet test --no-build --configuration Release --logger GitHubActions
- name: Upload build output
uses: actions/upload-artifact@v7
with:
name: build-output
path: |
src/*/bin/Release
tests/*/bin/Release
examples/*/bin/Release
if-no-files-found: error
retention-days: 1

unit-test:
name: Run unit tests
needs: build
runs-on: windows-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v7

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

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

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

- name: Download build output
uses: actions/download-artifact@v8
with:
name: build-output

- name: Unit test
run: dotnet test --no-build --configuration Release --filter "Category!=UI" --logger GitHubActions

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

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

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

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

- name: Download build output
uses: actions/download-artifact@v8
with:
name: build-output

- name: UI test
run: >-
dotnet test --no-build --configuration Release --filter "Category=UI"
--logger GitHubActions --logger trx --results-directory TestResults

- name: Upload UI test results
if: failure()
uses: actions/upload-artifact@v7
with:
name: ui-test-results
path: TestResults
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,7 @@ nunit-*.xml
*.env.staging

*.idea
.vs/
.vs/

# User-specific IDE files
*.user
10 changes: 10 additions & 0 deletions Directory.Build.props
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<Project>
<PropertyGroup>
<TargetFramework>net10.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<!-- Lets the solution build on a non-Windows machine (for example an IDE on macOS); running it still requires Windows. -->
<EnableWindowsTargeting>true</EnableWindowsTargeting>
</PropertyGroup>
</Project>
14 changes: 14 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<Project>
<!-- Central Package Management: every package version is set here, once. -->
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>

<ItemGroup>
<PackageVersion Include="GitHubActionsTestLogger" Version="3.0.5" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.10.1" />
<PackageVersion Include="NUnit" Version="4.6.1" />
<PackageVersion Include="NUnit.Analyzers" Version="4.15.0" />
<PackageVersion Include="NUnit3TestAdapter" Version="6.3.0" />
</ItemGroup>
</Project>
21 changes: 20 additions & 1 deletion WinUia.slnx
Original file line number Diff line number Diff line change
@@ -1 +1,20 @@
<Solution />
<Solution>
<Folder Name="/src/">
<Project Path="src/WinUia.NUnit/WinUia.NUnit.csproj" />
<Project Path="src/WinUia/WinUia.csproj" />
<Project Path="src/WinUia.Core/WinUia.Core.csproj" />
<Project Path="src/WinUia.Input/WinUia.Input.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/WinUia.NUnit.UnitTests/WinUia.NUnit.UnitTests.csproj" />
<Project Path="tests/WinUia.Testing.Shared/WinUia.Testing.Shared.csproj" />
<Project Path="tests/WinUia.UnitTests/WinUia.UnitTests.csproj" />
<Project Path="tests/WinUia.Core.UnitTests/WinUia.Core.UnitTests.csproj" />
<Project Path="tests/WinUia.Input.UnitTests/WinUia.Input.UnitTests.csproj" />
<Project Path="tests/WinUia.TestApp/WinUia.TestApp.csproj" />
</Folder>
<Folder Name="/examples/">
<Project Path="examples/WinUia.Examples.Winforms/WinUia.Examples.Winforms.csproj" />
<Project Path="examples/WinUia.Examples.Winforms.Tests/WinUia.Examples.Winforms.Tests.csproj" />
</Folder>
</Solution>
11 changes: 11 additions & 0 deletions examples/Directory.Build.props
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<Project>
<Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)../'))" />

<!--
Examples are written the way a WinUia user would write them: they use the WinUia modules' public API only. Nothing
under tests/ references them and they reference nothing under tests/.
-->
<PropertyGroup>
<IsPackable>false</IsPackable>
</PropertyGroup>
</Project>
46 changes: 46 additions & 0 deletions examples/WinUia.Examples.Winforms.Tests/Application/MainForm.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
using WinUia.Core;

namespace WinUia.Examples.Winforms.Tests.Application;

/// <summary>
/// Page object for the example app: the <see cref="App"/> itself, with the controls of its main window. Get it with
/// <c>App.LaunchApplication()</c> (<see cref="Extensions.AppExtensions"/>). The tab pages and the dialog are page objects
/// of their own, built on the elements they cover.
/// </summary>
public sealed class MainForm : App
{
public Element Window => MainWindow;
public Element Button => Window.FindByAutomationId("btnClick");
public Element ResultLabel => Window.FindByAutomationId("lblResult");
public Element InputBox => Window.FindByAutomationId("txtInput");
public Element ToggleCheckBox => Window.FindByAutomationId("chkToggle");
public Element ItemsList => Window.FindByAutomationId("lstItems");
public Element RecreateButton => Window.FindByAutomationId("btnRecreate");
public Element VolatileButton => Window.FindByAutomationId("btnVolatile");
public Element ItemsPanel => Window.FindByAutomationId("pnlItems");
public Element ReverseButton => Window.FindByAutomationId("btnReverse");
public Element OpenDialogButton => Window.FindByAutomationId("btnOpenDialog");
public Element TabControl => Window.FindByAutomationId("tabMain");

public DialogForm OpenDialog()
{
OpenDialogButton.Click();
return new DialogForm(FindWindow("WinUia Dialog"));
}

// The tab header (TabItem) and its page (Pane) share the tab's text, so ask for the TabItem explicitly.
public Element TabHeader(string text) =>
TabControl.Find(e => e.Name == text && e.ControlType == ControlType.TabItem, TreeScope.Children);

public Tab1 SelectTab1()
{
TabHeader("Tab 1").Select();
return new Tab1(Window);
}

public Tab2 SelectTab2()
{
TabHeader("Tab 2").Select();
return new Tab2(Window);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using WinUia.Core;


namespace WinUia.Examples.Winforms.Tests.Application;

public sealed class DialogForm(Element window)
{
public Element Window { get; } = window;
public Element MessageLabel => Window.FindByAutomationId("lblDialogMessage");
public Element InputBox => Window.FindByAutomationId("txtDialogInput");
public Element OkButton => Window.FindByAutomationId("btnDialogOk");
public Element CancelButton => Window.FindByAutomationId("btnDialogCancel");
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using WinUia.Core;

namespace WinUia.Examples.Winforms.Tests.Application;

/// <summary>The content of the first tab page, found under the main window.</summary>
public sealed class Tab1(Element window)
{
public Element Content => window.FindByAutomationId("lblTab1");
}
Loading