diff --git a/.github/README.md b/.github/README.md index f808c70..042ae7e 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

@@ -32,6 +32,7 @@ Getting Started
  • @@ -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 ``` @@ -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) @@ -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`, `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`): +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`, `App.Attach`) 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(); // 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. +using var app = App.Launch(); // its executable and defaults +using var quiet = App.Launch(options: new AppLaunchOptions { ShowPointer = false }); // one default overridden +using var other = App.Launch(@"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: @@ -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. @@ -206,13 +233,13 @@ Distributed under the GNU Affero General Public License v3.0 License. See `LICEN -[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 diff --git a/.github/actions/setup/action.yaml b/.github/actions/setup/action.yaml new file mode 100644 index 0000000..c1fccf3 --- /dev/null +++ b/.github/actions/setup/action.yaml @@ -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 diff --git a/.github/workflows/build-and-test.yaml b/.github/workflows/build-and-test.yaml index 62981bb..e5a883d 100644 --- a/.github/workflows/build-and-test.yaml +++ b/.github/workflows/build-and-test.yaml @@ -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 @@ -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 @@ -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 @@ -86,8 +59,6 @@ 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 @@ -95,22 +66,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 diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index ddf8693..0ba7108 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -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] @@ -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 @@ -20,14 +20,17 @@ 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 @@ -35,14 +38,13 @@ jobs: 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 @@ -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 diff --git a/Directory.Build.props b/Directory.Build.props index cea0c5d..76975c2 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -4,7 +4,6 @@ enable enable true - true diff --git a/Directory.Packages.props b/Directory.Packages.props index 49055e0..5404ad5 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,11 +1,11 @@ - true + diff --git a/WinUia.slnx b/WinUia.slnx index 320f723..2a22885 100644 --- a/WinUia.slnx +++ b/WinUia.slnx @@ -3,7 +3,6 @@ - @@ -12,9 +11,6 @@ - - - @@ -22,7 +18,7 @@ + - diff --git a/examples/Directory.Build.props b/examples/Directory.Build.props index 221014c..3b40914 100644 --- a/examples/Directory.Build.props +++ b/examples/Directory.Build.props @@ -1,10 +1,6 @@ - false diff --git a/examples/WinUia.Examples.Winforms.Tests/Application/Tab1.cs b/examples/WinUia.Examples.Winforms.Tests/Application/Tab1.cs deleted file mode 100644 index 847eef1..0000000 --- a/examples/WinUia.Examples.Winforms.Tests/Application/Tab1.cs +++ /dev/null @@ -1,9 +0,0 @@ -using WinUia.Core; - -namespace WinUia.Examples.Winforms.Tests.Application; - -/// The content of the first tab page, found under the main window. -public sealed class Tab1(Element window) -{ - public Element Content => window.FindByAutomationId("lblTab1"); -} diff --git a/examples/WinUia.Examples.Winforms.Tests/Application/Tab2.cs b/examples/WinUia.Examples.Winforms.Tests/Application/Tab2.cs deleted file mode 100644 index 43a2bbe..0000000 --- a/examples/WinUia.Examples.Winforms.Tests/Application/Tab2.cs +++ /dev/null @@ -1,9 +0,0 @@ -using WinUia.Core; - -namespace WinUia.Examples.Winforms.Tests.Application; - -/// The content of the second tab page, found under the main window. -public sealed class Tab2(Element window) -{ - public Element Content => window.FindByAutomationId("lblTab2"); -} diff --git a/examples/WinUia.Examples.Winforms.Tests/Application/WinFormsApp.cs b/examples/WinUia.Examples.Winforms.Tests/Application/WinFormsApp.cs deleted file mode 100644 index 390533a..0000000 --- a/examples/WinUia.Examples.Winforms.Tests/Application/WinFormsApp.cs +++ /dev/null @@ -1,55 +0,0 @@ -using System.Reflection; -using WinUia.Core; - -namespace WinUia.Examples.Winforms.Tests.Application; - -/// -/// 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 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"); - 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); - } -} diff --git a/examples/WinUia.Examples.Winforms.Tests/MainFormTests.cs b/examples/WinUia.Examples.Winforms.Tests/MainFormTests.cs deleted file mode 100644 index a68b5ce..0000000 --- a/examples/WinUia.Examples.Winforms.Tests/MainFormTests.cs +++ /dev/null @@ -1,107 +0,0 @@ -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/TabTests.cs b/examples/WinUia.Examples.Winforms.Tests/TabTests.cs deleted file mode 100644 index 13e9891..0000000 --- a/examples/WinUia.Examples.Winforms.Tests/TabTests.cs +++ /dev/null @@ -1,72 +0,0 @@ -using WinUia.Examples.Winforms.Tests.Application; - -namespace WinUia.Examples.Winforms.Tests; - -[UiTest] -public sealed class TabTests -{ - private WinFormsApp _app = null!; - - [SetUp] - public void SetUp() => _app = App.Launch(); - - [Test] - public void Tab_1_is_selected_at_start() - { - using (Assert.EnterMultipleScope()) - { - 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 = _app.SelectTab1(); - - Assert.That(tab1.Content.Name, Is.EqualTo("tab1 content")); - } - - [Test] - public void SelectTab2_selects_the_tab_and_shows_the_tab_2_content() - { - var tab2 = _app.SelectTab2(); - - using (Assert.EnterMultipleScope()) - { - Assert.That(_app.TabHeader("Tab 2").SelectionItemPattern.IsSelected, Is.True); - Assert.That(tab2.Content.Name, Is.EqualTo("tab2 content")); - } - } - - [Test] - public void Switching_tabs_hides_the_other_tabs_content() - { - _app.SelectTab2(); - var tab1 = _app.SelectTab1(); - - using (Assert.EnterMultipleScope()) - { - Assert.That(tab1.Content.Name, Is.EqualTo("tab1 content")); - 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 = _app.SelectTab2(); - - _app.Button.Click(); - - using (Assert.EnterMultipleScope()) - { - Eventually(() => _app.ResultLabel.Name == "Clicked"); - Assert.That(tab2.Content.Name, Is.EqualTo("tab2 content")); - } - } - - [TearDown] - public void TearDown() => _app.Dispose(); -} diff --git a/examples/WinUia.Examples.Winforms.Tests/Application/DialogForm.cs b/examples/WinUia.Examples.Winforms.UiTests/Application/Views/DialogForm.cs similarity index 84% rename from examples/WinUia.Examples.Winforms.Tests/Application/DialogForm.cs rename to examples/WinUia.Examples.Winforms.UiTests/Application/Views/DialogForm.cs index a28e525..eb943d1 100644 --- a/examples/WinUia.Examples.Winforms.Tests/Application/DialogForm.cs +++ b/examples/WinUia.Examples.Winforms.UiTests/Application/Views/DialogForm.cs @@ -1,7 +1,4 @@ -using WinUia.Core; - - -namespace WinUia.Examples.Winforms.Tests.Application; +namespace WinUia.Examples.Winforms.UiTests.Application.Views; public sealed class DialogForm(Element window) { diff --git a/examples/WinUia.Examples.Winforms.UiTests/Application/Views/MainForm.cs b/examples/WinUia.Examples.Winforms.UiTests/Application/Views/MainForm.cs new file mode 100644 index 0000000..f8c9c14 --- /dev/null +++ b/examples/WinUia.Examples.Winforms.UiTests/Application/Views/MainForm.cs @@ -0,0 +1,38 @@ +namespace WinUia.Examples.Winforms.UiTests.Application.Views; + +public class MainForm(WinFormsApp app) +{ + public Element Window => app.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(app.FindWindow("WinUia Dialog")); + } + + 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(app); + } + + public Tab2 SelectTab2() + { + TabHeader("Tab 2").Select(); + return new Tab2(app); + } +} diff --git a/examples/WinUia.Examples.Winforms.UiTests/Application/Views/Tab1.cs b/examples/WinUia.Examples.Winforms.UiTests/Application/Views/Tab1.cs new file mode 100644 index 0000000..eb7cccc --- /dev/null +++ b/examples/WinUia.Examples.Winforms.UiTests/Application/Views/Tab1.cs @@ -0,0 +1,6 @@ +namespace WinUia.Examples.Winforms.UiTests.Application.Views; + +public class Tab1(WinFormsApp app) : MainForm(app) +{ + public Element Content => Window.FindByAutomationId("lblTab1"); +} diff --git a/examples/WinUia.Examples.Winforms.UiTests/Application/Views/Tab2.cs b/examples/WinUia.Examples.Winforms.UiTests/Application/Views/Tab2.cs new file mode 100644 index 0000000..8f2cb1a --- /dev/null +++ b/examples/WinUia.Examples.Winforms.UiTests/Application/Views/Tab2.cs @@ -0,0 +1,6 @@ +namespace WinUia.Examples.Winforms.UiTests.Application.Views; + +public class Tab2(WinFormsApp app) : MainForm(app) +{ + public Element Content => Window.FindByAutomationId("lblTab2"); +} diff --git a/examples/WinUia.Examples.Winforms.UiTests/Application/WinFormsApp.cs b/examples/WinUia.Examples.Winforms.UiTests/Application/WinFormsApp.cs new file mode 100644 index 0000000..cb4e1b8 --- /dev/null +++ b/examples/WinUia.Examples.Winforms.UiTests/Application/WinFormsApp.cs @@ -0,0 +1,11 @@ +using System.Reflection; +using WinUia.Models; + +namespace WinUia.Examples.Winforms.UiTests.Application; + +public sealed class WinFormsApp : App +{ + protected override string ExecutablePath => typeof(WinFormsApp).Assembly.GetCustomAttributes() + .Single(a => a.Key == "ApplicationPath").Value!; + protected override AppLaunchOptions DefaultOptions => new() { ShowPointer = true }; +} \ No newline at end of file diff --git a/examples/WinUia.Examples.Winforms.UiTests/Fixtures/WinFormsFixture.cs b/examples/WinUia.Examples.Winforms.UiTests/Fixtures/WinFormsFixture.cs new file mode 100644 index 0000000..aab667d --- /dev/null +++ b/examples/WinUia.Examples.Winforms.UiTests/Fixtures/WinFormsFixture.cs @@ -0,0 +1,20 @@ +using WinUia.Examples.Winforms.UiTests.Application; +using WinUia.Examples.Winforms.UiTests.Application.Views; + +namespace WinUia.Examples.Winforms.UiTests.Fixtures; + +public abstract class WinFormsFixture +{ + protected WinFormsApp WinFormsApp { get; private set; } = null!; + protected MainForm MainForm { get; private set; } = null!; + + [SetUp] + public void LaunchApp() + { + WinFormsApp = App.Launch(); + MainForm = new MainForm(WinFormsApp); + } + + [TearDown] + public void CloseApp() => WinFormsApp.Dispose(); +} diff --git a/examples/WinUia.Examples.Winforms.Tests/DialogTests.cs b/examples/WinUia.Examples.Winforms.UiTests/Tests/DialogTests.cs similarity index 61% rename from examples/WinUia.Examples.Winforms.Tests/DialogTests.cs rename to examples/WinUia.Examples.Winforms.UiTests/Tests/DialogTests.cs index 6b8a048..29624cb 100644 --- a/examples/WinUia.Examples.Winforms.Tests/DialogTests.cs +++ b/examples/WinUia.Examples.Winforms.UiTests/Tests/DialogTests.cs @@ -1,22 +1,16 @@ using System.Diagnostics; -using WinUia.Core.Exceptions; -using WinUia.Examples.Winforms.Tests.Application; +using WinUia.Examples.Winforms.UiTests.Fixtures; -namespace WinUia.Examples.Winforms.Tests; +namespace WinUia.Examples.Winforms.UiTests.Tests; -[UiTest] -public sealed class DialogTests +public sealed class DialogTests : WinFormsFixture { - private WinFormsApp _app = null!; - - [SetUp] - public void SetUp() => _app = App.Launch(); [Test] public void Opening_the_dialog_shows_a_modal_window() { var stopwatch = Stopwatch.StartNew(); - var dialog = _app.OpenDialog(); + var dialog = MainForm.OpenDialog(); using (Assert.EnterMultipleScope()) { @@ -30,34 +24,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(_app.TryFindWindow("No such window", TimeSpan.FromMilliseconds(100)), Is.Null); + Assert.That(WinFormsApp.TryFindWindow("No such window", TimeSpan.FromMilliseconds(100)), Is.Null); } [Test] public void Confirming_the_dialog_reports_the_entered_value() { - var dialog = _app.OpenDialog(); + var dialog = MainForm.OpenDialog(); dialog.InputBox.SetValue("hello"); dialog.OkButton.Click(); - Eventually(() => _app.ResultLabel.Name == "Dialog: OK (hello)"); + Eventually(() => MainForm.ResultLabel.Name == "Dialog: OK (hello)"); } [Test] public void Cancelling_the_dialog_reports_cancel() { - var dialog = _app.OpenDialog(); + var dialog = MainForm.OpenDialog(); dialog.CancelButton.Click(); - Eventually(() => _app.ResultLabel.Name == "Dialog: Cancel"); + Eventually(() => MainForm.ResultLabel.Name == "Dialog: Cancel"); } [Test] public void Closing_the_dialog_removes_it() { - var dialog = _app.OpenDialog(); + var dialog = MainForm.OpenDialog(); dialog.CancelButton.Click(); @@ -70,11 +64,8 @@ public void Closing_the_dialog_removes_it() } catch (UiaStaleElementException) { - return true; // Closed: the window is gone and cannot be found again. + return true; } }); } - - [TearDown] - public void TearDown() => _app.Dispose(); } diff --git a/examples/WinUia.Examples.Winforms.UiTests/Tests/MainFormTests.cs b/examples/WinUia.Examples.Winforms.UiTests/Tests/MainFormTests.cs new file mode 100644 index 0000000..7e50e10 --- /dev/null +++ b/examples/WinUia.Examples.Winforms.UiTests/Tests/MainFormTests.cs @@ -0,0 +1,101 @@ +using WinUia.Examples.Winforms.UiTests.Fixtures; +using WinUia.Patterns; + +namespace WinUia.Examples.Winforms.UiTests.Tests; + +public sealed class MainFormTests : WinFormsFixture +{ + + [SetUp] + public void SelectTab() => MainForm.SelectTab1(); + + [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(WinFormsApp.Process.Id)); + } + } + + [Test] + public void Find_falls_back_to_the_name() + { + Assert.That(WinFormsApp.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)); + } + + private IEnumerable ItemNames() => MainForm.ItemsPanel.FindAll(e => e.ControlType == ControlType.Button, TreeScope.Children).Select(i => i.Name); +} diff --git a/examples/WinUia.Examples.Winforms.Tests/PatternTests.cs b/examples/WinUia.Examples.Winforms.UiTests/Tests/PatternTests.cs similarity index 52% rename from examples/WinUia.Examples.Winforms.Tests/PatternTests.cs rename to examples/WinUia.Examples.Winforms.UiTests/Tests/PatternTests.cs index eb163d2..9cbf952 100644 --- a/examples/WinUia.Examples.Winforms.Tests/PatternTests.cs +++ b/examples/WinUia.Examples.Winforms.UiTests/Tests/PatternTests.cs @@ -1,32 +1,20 @@ -using WinUia.Core.Exceptions; -using WinUia.Core.Patterns; -using WinUia.Core; -using WinUia.Examples.Winforms.Tests.Application; +using WinUia.Examples.Winforms.UiTests.Fixtures; +using WinUia.Patterns; -namespace WinUia.Examples.Winforms.Tests; +namespace WinUia.Examples.Winforms.UiTests.Tests; -/// Control patterns, searches, tree navigation and physical input, exercised on the example app's controls. - -[UiTest] -public sealed class PatternTests +public sealed partial class PatternTests : WinFormsFixture { - private WinFormsApp _app = null!; - - [SetUp] - public void SetUp() => _app = App.Launch(); - - [TearDown] - public void TearDown() => _app.Dispose(); - [System.Runtime.InteropServices.DllImport("user32.dll", EntryPoint = "GetWindowLongPtrW")] - private static extern nint GetWindowLongPtr(nint hwnd, int index); + [System.Runtime.InteropServices.LibraryImport("user32.dll", EntryPoint = "GetWindowLongPtrW")] + private static partial nint GetWindowLongPtr(nint hwnd, int index); private static bool IsTopmostWindow(nint hwnd) => (GetWindowLongPtr(hwnd, -20 /* GWL_EXSTYLE */) & 0x8 /* WS_EX_TOPMOST */) != 0; [Test] public void Unsupported_pattern_throws() { - var label = _app.ResultLabel; + var label = MainForm.ResultLabel; Assert.That(label.InvokePattern.IsSupported, Is.False); Assert.Throws(() => label.InvokePattern.Invoke()); @@ -37,15 +25,15 @@ public void Window_pattern_reports_window_state() { using (Assert.EnterMultipleScope()) { - Assert.That(_app.Window.WindowPattern.IsSupported, Is.True); - Assert.That(_app.Window.WindowPattern.VisualState, Is.EqualTo(WindowVisualState.Normal)); + Assert.That(MainForm.Window.WindowPattern.IsSupported, Is.True); + Assert.That(MainForm.Window.WindowPattern.VisualState, Is.EqualTo(WindowVisualState.Normal)); } } [Test] public void GetText_reads_the_value_of_an_edit() { - var input = _app.InputBox; + var input = MainForm.InputBox; input.SetValue("some text"); Assert.That(input.GetText(), Is.EqualTo("some text")); @@ -54,7 +42,7 @@ public void GetText_reads_the_value_of_an_edit() [Test] public void Clickable_point_is_inside_the_bounding_rectangle() { - var button = _app.Button; + var button = MainForm.Button; var point = button.GetClickablePoint(); var rect = button.BoundingRectangle; @@ -69,9 +57,9 @@ public void Clickable_point_is_inside_the_bounding_rectangle() [Test] public void Composite_conditions_search_correctly() { - 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); + 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); using (Assert.EnterMultipleScope()) { @@ -84,9 +72,9 @@ public void Composite_conditions_search_correctly() [Test] public void Predicate_searches_find_the_expected_elements() { - 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); + 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); using (Assert.EnterMultipleScope()) { @@ -99,29 +87,29 @@ public void Predicate_searches_find_the_expected_elements() [Test] public void FromPoint_and_GetFocusedElement_return_the_expected_elements() { - var input = _app.InputBox; + var input = MainForm.InputBox; input.Focus(); - Eventually(() => _app.Context.GetFocusedElement().AutomationId == "txtInput", "SetFocus moves keyboard focus"); - Assert.That(_app.Context.FromPoint(_app.Button.GetClickablePoint()).AutomationId, Is.EqualTo("btnClick")); + Eventually(() => WinFormsApp.Context.GetFocusedElement().AutomationId == "txtInput", "SetFocus moves keyboard focus"); + Assert.That(WinFormsApp.Context.FromPoint(MainForm.Button.GetClickablePoint()).AutomationId, Is.EqualTo("btnClick")); } [Test] public void Parent_and_Ancestors_walk_up_to_the_root() { - var button = _app.Button; + var button = MainForm.Button; using (Assert.EnterMultipleScope()) { - Assert.That(button.Parent!.IsSameAs(_app.Window), Is.True); - Assert.That(button.Ancestors().Last().IsSameAs(_app.Context.GetRootElement()), Is.True); + Assert.That(button.Parent!.IsSameAs(MainForm.Window), Is.True); + Assert.That(button.Ancestors().Last().IsSameAs(WinFormsApp.Context.GetRootElement()), Is.True); } } [Test] public void AddToSelection_and_RemoveFromSelection_change_a_multi_select_list() { - var list = _app.ItemsList; + var list = MainForm.ItemsList; var alpha = list.Find(e => e.Name == "Alpha"); var gamma = list.Find(e => e.Name == "Gamma"); @@ -144,15 +132,14 @@ public void AddToSelection_and_RemoveFromSelection_change_a_multi_select_list() [Test] public void Window_pattern_members_work() { - var window = _app.Window.WindowPattern; + var window = MainForm.Window.WindowPattern; using (Assert.EnterMultipleScope()) { Assert.That(window.CanMaximize, Is.True); 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(_app.Window.NativeWindowHandle))); + Assert.That(window.IsTopmost, Is.EqualTo(IsTopmostWindow(MainForm.Window.NativeWindowHandle))); } // The WinForms provider returns E_NOTIMPL for WaitForInputIdle. @@ -167,42 +154,41 @@ public void Window_pattern_members_work() [Test] public void With_ShowPointer_the_cursor_moves_to_each_element_before_the_interaction() { - _app.Context.ShowPointer = true; - _app.Context.Input.MoveTo(5, 5); + WinFormsApp.Context.ShowPointer = true; + WinFormsApp.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(); + MainForm.Button.Click(); + var afterClick = WinFormsApp.Context.Input.GetCursorPosition(); + MainForm.InputBox.SetValue("pointed at"); + var afterSetValue = WinFormsApp.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")); + Assert.That(Contains(MainForm.Button.BoundingRectangle, afterClick), Is.True, "the cursor is on the button after Click"); + Assert.That(Contains(MainForm.InputBox.BoundingRectangle, afterSetValue), Is.True, "the cursor is on the text box after SetValue"); + Assert.That(MainForm.InputBox.ValuePattern.Value, Is.EqualTo("pointed at")); } - Eventually(() => _app.ResultLabel.Name == "Clicked", "the click still goes through"); + Eventually(() => MainForm.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. + WinFormsApp.Context.ShowPointer = false; + WinFormsApp.Context.Input.MoveTo(5, 5); - _app.Button.Click(); - var afterClick = _app.Context.Input.GetCursorPosition(); - _app.InputBox.SetValue("not pointed at"); - var afterSetValue = _app.Context.Input.GetCursorPosition(); + MainForm.Button.Click(); + var afterClick = WinFormsApp.Context.Input.GetCursorPosition(); + MainForm.InputBox.SetValue("not pointed at"); + var afterSetValue = WinFormsApp.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. + // Asserts the cursor is not on the elements rather than that it stays at (5, 5), which user mouse movement would break. 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")); + Assert.That(Contains(MainForm.Button.BoundingRectangle, afterClick), Is.False, "Click does not point at the button"); + Assert.That(Contains(MainForm.InputBox.BoundingRectangle, afterSetValue), Is.False, "SetValue does not point at the text box"); + Assert.That(MainForm.InputBox.ValuePattern.Value, Is.EqualTo("not pointed at")); } } @@ -212,12 +198,12 @@ private static bool Contains(ScreenRect rect, (int X, int Y) point) => [Test] public void Physical_click_and_typing_reach_the_app() { - var input = _app.InputBox; + var input = MainForm.InputBox; input.PhysicalClick(); Eventually(() => input.HasKeyboardFocus, "a physical click focuses the text box"); - _app.Context.Input.SendText("typed"); + WinFormsApp.Context.Input.SendText("typed"); Eventually(() => input.ValuePattern.Value == "typed", "SendText types into the focused text box"); } } diff --git a/examples/WinUia.Examples.Winforms.Tests/SelfHealingTests.cs b/examples/WinUia.Examples.Winforms.UiTests/Tests/SelfHealingTests.cs similarity index 60% rename from examples/WinUia.Examples.Winforms.Tests/SelfHealingTests.cs rename to examples/WinUia.Examples.Winforms.UiTests/Tests/SelfHealingTests.cs index 2fb9f46..dfca148 100644 --- a/examples/WinUia.Examples.Winforms.Tests/SelfHealingTests.cs +++ b/examples/WinUia.Examples.Winforms.UiTests/Tests/SelfHealingTests.cs @@ -1,39 +1,23 @@ -using WinUia.Core.Exceptions; -using WinUia.Core; -using WinUia.Examples.Winforms.Tests.Application; - -namespace WinUia.Examples.Winforms.Tests; - -/// -/// Elements re-find themselves when their control is re-created: FindFirst results through their search, FindAll -/// results by identity. The example app's "Recreate" button replaces btnVolatile; "Reverse" re-creates the pnlItems buttons -/// in reverse order. -/// -[UiTest] -public sealed class SelfHealingTests -{ - private WinFormsApp _app = null!; - - [SetUp] - public void SetUp() => _app = App.Launch(); +using WinUia.Examples.Winforms.UiTests.Fixtures; - [TearDown] - public void TearDown() => _app.Dispose(); +namespace WinUia.Examples.Winforms.UiTests.Tests; - private IReadOnlyList Items() => _app.ItemsPanel.FindAll(e => e.ControlType == ControlType.Button, TreeScope.Children); +public sealed class SelfHealingTests : WinFormsFixture +{ + private IReadOnlyList Items() => MainForm.ItemsPanel.FindAll(e => e.ControlType == ControlType.Button, TreeScope.Children); private IEnumerable ItemNames() => Items().Select(i => i.Name); private void Recreate() { - _app.RecreateButton.Click(); - Eventually(() => _app.VolatileButton.Name == "Volatile 2", "Recreate replaces the volatile button"); + MainForm.RecreateButton.Click(); + Eventually(() => MainForm.VolatileButton.Name == "Volatile 2", "Recreate replaces the volatile button"); } [Test] public void A_found_element_re_resolves_after_its_control_is_recreated() { - var volatileButton = _app.VolatileButton; + var volatileButton = MainForm.VolatileButton; Assert.That(volatileButton.Name, Is.EqualTo("Volatile 1")); Recreate(); @@ -48,7 +32,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"])); - _app.ReverseButton.Click(); + MainForm.ReverseButton.Click(); Eventually(() => ItemNames().SequenceEqual(namesBefore.Reverse())); Assert.That(items.Select(i => i.Name), Is.EqualTo(namesBefore)); @@ -59,7 +43,7 @@ public void A_FindAll_element_never_read_before_going_stale_reports_stale_instea { var items = Items(); - _app.ReverseButton.Click(); + MainForm.ReverseButton.Click(); Eventually(() => ItemNames().First() == "Item C"); Assert.Throws(() => _ = items[0].Name); @@ -68,11 +52,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 = _app.VolatileButton; + var volatileButton = MainForm.VolatileButton; Assert.That(volatileButton.Name, Is.EqualTo("Volatile 1")); Recreate(); - _app.Context.StaleRetryCount = 0; + WinFormsApp.Context.StaleRetryCount = 0; Assert.Throws(() => _ = volatileButton.Name); } @@ -80,7 +64,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 = _app.Context.FromHandle(_app.VolatileButton.NativeWindowHandle); + var byHandle = WinFormsApp.Context.FromHandle(MainForm.VolatileButton.NativeWindowHandle); Assert.That(byHandle.Name, Is.EqualTo("Volatile 1")); Recreate(); @@ -91,11 +75,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 = _app.ItemsPanel; + var panel = MainForm.ItemsPanel; var itemA = panel.Find(e => e.Name == "Item A", TreeScope.Children); Assert.That(itemA.Name, Is.EqualTo("Item A")); - _app.ReverseButton.Click(); + MainForm.ReverseButton.Click(); Eventually(() => ItemNames().First() == "Item C"); Assert.That(itemA.Name, Is.EqualTo("Item A")); diff --git a/examples/WinUia.Examples.Winforms.UiTests/Tests/TabTests.cs b/examples/WinUia.Examples.Winforms.UiTests/Tests/TabTests.cs new file mode 100644 index 0000000..32d1999 --- /dev/null +++ b/examples/WinUia.Examples.Winforms.UiTests/Tests/TabTests.cs @@ -0,0 +1,72 @@ +using WinUia.Examples.Winforms.UiTests.Application; +using WinUia.Examples.Winforms.UiTests.Fixtures; + +namespace WinUia.Examples.Winforms.UiTests.Tests; + +public sealed class TabTests : WinFormsFixture +{ + + [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); + } + } + + [Test] + public void SelectTab1_shows_the_tab_1_content() + { + var tab1 = MainForm.SelectTab1(); + + Assert.That(tab1.Content.Name, Is.EqualTo("tab1 content")); + } + + [Test] + public void SelectTab2_selects_the_tab_and_shows_the_tab_2_content() + { + var tab2 = MainForm.SelectTab2(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(MainForm.TabHeader("Tab 2").SelectionItemPattern.IsSelected, Is.True); + Assert.That(tab2.Content.Name, Is.EqualTo("tab2 content")); + } + } + + [Test] + public void Switching_tabs_hides_the_other_tabs_content() + { + MainForm.SelectTab2(); + var tab1 = MainForm.SelectTab1(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(tab1.Content.Name, Is.EqualTo("tab1 content")); + Assert.That(MainForm.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(); + MainForm.Button.Click(); + + using (Assert.EnterMultipleScope()) + { + Eventually(() => MainForm.ResultLabel.Name == "Clicked"); + Assert.That(tab2.Content.Name, Is.EqualTo("tab2 content")); + } + } + + [Test] + public void A_tab_can_select_another_tab() + { + var tab1 = MainForm.SelectTab2().SelectTab1(); + + Assert.That(tab1.Content.Name, Is.EqualTo("tab1 content")); + } +} diff --git a/examples/WinUia.Examples.Winforms.UiTests/Tests/WinFormsAppTests.cs b/examples/WinUia.Examples.Winforms.UiTests/Tests/WinFormsAppTests.cs new file mode 100644 index 0000000..44e8c4c --- /dev/null +++ b/examples/WinUia.Examples.Winforms.UiTests/Tests/WinFormsAppTests.cs @@ -0,0 +1,14 @@ +using WinUia.Examples.Winforms.UiTests.Application; + +namespace WinUia.Examples.Winforms.UiTests.Tests; + +[UiTest] +public class WinFormsAppTests +{ + [Test] + public void Application_launches_and_closes_successfully() + { + using var app = App.Launch(); + app.Close(); + } +} \ No newline at end of file diff --git a/examples/WinUia.Examples.Winforms.Tests/WinUia.Examples.Winforms.Tests.csproj b/examples/WinUia.Examples.Winforms.UiTests/WinUia.Examples.Winforms.UiTests.csproj similarity index 73% rename from examples/WinUia.Examples.Winforms.Tests/WinUia.Examples.Winforms.Tests.csproj rename to examples/WinUia.Examples.Winforms.UiTests/WinUia.Examples.Winforms.UiTests.csproj index 82a6f80..bae0d2d 100644 --- a/examples/WinUia.Examples.Winforms.Tests/WinUia.Examples.Winforms.Tests.csproj +++ b/examples/WinUia.Examples.Winforms.UiTests/WinUia.Examples.Winforms.UiTests.csproj @@ -1,9 +1,9 @@ - + + + true + @@ -17,12 +17,9 @@ - - - <_Parameter1>ApplicationPath @@ -31,11 +28,16 @@ - + + + <_Parameter1>System.Threading.ApartmentState.MTA <_Parameter1_IsLiteral>true + + + diff --git a/examples/WinUia.Examples.Winforms/DialogForm.Designer.cs b/examples/WinUia.Examples.Winforms/DialogForm.Designer.cs index 2c35ca5..fe78c23 100644 --- a/examples/WinUia.Examples.Winforms/DialogForm.Designer.cs +++ b/examples/WinUia.Examples.Winforms/DialogForm.Designer.cs @@ -90,6 +90,7 @@ private void InitializeComponent() ShowInTaskbar = false; StartPosition = FormStartPosition.CenterParent; Text = "WinUia Dialog"; + TopMost = true; ResumeLayout(false); PerformLayout(); } diff --git a/examples/WinUia.Examples.Winforms/DialogForm.cs b/examples/WinUia.Examples.Winforms/DialogForm.cs index 899ec29..2821459 100644 --- a/examples/WinUia.Examples.Winforms/DialogForm.cs +++ b/examples/WinUia.Examples.Winforms/DialogForm.cs @@ -1,6 +1,5 @@ namespace WinUia.Examples.Winforms; -/// Modal dialog opened by btnOpenDialog: asks for a value, closed with OK or Cancel. public partial class DialogForm : Form { public DialogForm() @@ -8,6 +7,5 @@ public DialogForm() InitializeComponent(); } - /// The value typed into the dialog. public string InputText => txtDialogInput.Text; } diff --git a/examples/WinUia.Examples.Winforms/DialogForm.resx b/examples/WinUia.Examples.Winforms/DialogForm.resx new file mode 100644 index 0000000..1af7de1 --- /dev/null +++ b/examples/WinUia.Examples.Winforms/DialogForm.resx @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + \ No newline at end of file diff --git a/examples/WinUia.Examples.Winforms/MainForm.cs b/examples/WinUia.Examples.Winforms/MainForm.cs index 7c1d6cc..9052943 100644 --- a/examples/WinUia.Examples.Winforms/MainForm.cs +++ b/examples/WinUia.Examples.Winforms/MainForm.cs @@ -1,10 +1,5 @@ namespace WinUia.Examples.Winforms; -/// -/// Main window of the WinForms example app, automated by examples/WinUia.Examples.Winforms.Tests. The controls are laid out in the designer; WinForms exposes each -/// control's as its UI Automation AutomationId, so those names are the ids the tests -/// search for. The window is TopMost because the physical-input tests click screen coordinates. -/// public partial class MainForm : Form { private int _generation = 1; @@ -17,7 +12,6 @@ public MainForm() // 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. private void btnOpenDialog_Click(object? sender, EventArgs e) { using var dialog = new DialogForm(); @@ -25,10 +19,6 @@ private void btnOpenDialog_Click(object? sender, EventArgs e) lblResult.Text = result == DialogResult.OK ? $"Dialog: OK ({dialog.InputText})" : $"Dialog: {result}"; } - /// - /// Destroys btnVolatile and creates a new one with the same AutomationId, which makes elements found earlier - /// stale (their window handle is gone) — the WinUI 3 re-render case. - /// private void btnRecreate_Click(object? sender, EventArgs e) { var old = btnVolatile; @@ -38,10 +28,7 @@ private void btnRecreate_Click(object? sender, EventArgs e) Controls.Add(btnVolatile); } - /// - /// Recreates the buttons in pnlItems in reverse order: every button gets a new window handle and most change - /// position, so index-based re-resolution would pick the wrong one. - /// + // Reversed with new window handles, so index-based re-resolution would pick the wrong button. private void btnReverse_Click(object? sender, EventArgs e) { var old = pnlItems.Controls.Cast