mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-07-01 16:09:46 +02:00
Compare commits
62 Commits
powerscrip
...
pt-team/ui
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe0400d5ca | ||
|
|
fccf9aec14 | ||
|
|
9defe2f7fa | ||
|
|
7d9dbc0087 | ||
|
|
7da6ceacdd | ||
|
|
75faf43360 | ||
|
|
8645c20c72 | ||
|
|
2b5ac5f369 | ||
|
|
ef97ba3ec5 | ||
|
|
e508705991 | ||
|
|
156c63e0e9 | ||
|
|
59872771ed | ||
|
|
e53b0dacd9 | ||
|
|
1932466b8e | ||
|
|
5ccdcdc6cc | ||
|
|
45484f1c40 | ||
|
|
2f2e8f1827 | ||
|
|
a326168e54 | ||
|
|
a0edb0ed1b | ||
|
|
3bbee56c89 | ||
|
|
dce64e1056 | ||
|
|
ee9f33918d | ||
|
|
45e912ab24 | ||
|
|
fb88583cdf | ||
|
|
004fd634f7 | ||
|
|
7cf145b7f1 | ||
|
|
10db8a3f27 | ||
|
|
901383a9d9 | ||
|
|
f84c4bbf39 | ||
|
|
76b2046359 | ||
|
|
934047328f | ||
|
|
8900ae0835 | ||
|
|
8dcd2d48cd | ||
|
|
9c9566d1fd | ||
|
|
446009daba | ||
|
|
96d8d70636 | ||
|
|
f13dd6eb61 | ||
|
|
5c487a5be3 | ||
|
|
0b0a698fed | ||
|
|
858b9ea2db | ||
|
|
7ead88631c | ||
|
|
40044ae268 | ||
|
|
892fe244d7 | ||
|
|
309ffe5515 | ||
|
|
0a93c4d179 | ||
|
|
eea70294ec | ||
|
|
6183b99020 | ||
|
|
bfd089fc45 | ||
|
|
1208c019c1 | ||
|
|
45da84b377 | ||
|
|
6ddfbdee48 | ||
|
|
715e1bd0dd | ||
|
|
5740651c63 | ||
|
|
e8df5a7c84 | ||
|
|
ba6612f375 | ||
|
|
d7f6f83b71 | ||
|
|
294bbcc029 | ||
|
|
63bd903d2d | ||
|
|
7ee6bc6500 | ||
|
|
cf3d132a8f | ||
|
|
c37eaf00c3 | ||
|
|
3bc472bcda |
2
.github/actions/spell-check/expect.txt
vendored
2
.github/actions/spell-check/expect.txt
vendored
@@ -2075,6 +2075,8 @@ wifi
|
||||
wikimedia
|
||||
wikipedia
|
||||
winapi
|
||||
winapp
|
||||
winappcli
|
||||
winappsdk
|
||||
windir
|
||||
WINDOWCREATED
|
||||
|
||||
201
.github/skills/ui-tests-migration/LICENSE.txt
vendored
Normal file
201
.github/skills/ui-tests-migration/LICENSE.txt
vendored
Normal file
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to the Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2026 Microsoft Corporation
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
192
.github/skills/ui-tests-migration/SKILL.md
vendored
Normal file
192
.github/skills/ui-tests-migration/SKILL.md
vendored
Normal file
@@ -0,0 +1,192 @@
|
||||
---
|
||||
name: ui-tests-migration
|
||||
description: "Migrate PowerToys module UI tests from the legacy WinAppDriver/Selenium harness (Microsoft.PowerToys.UITest) to the new winappcli-based harness (Microsoft.PowerToys.UITest.Next). Use when asked to port/convert/rewrite/modernize a module's UI tests to the .Next framework, create a new [Module].UITests.Next project alongside existing legacy tests, or stand up brand-new winappcli UI tests for a module that has none by reading its human test sign-off markdown. Covers the API mapping (By/Element/Session/UITestBase, KeyboardHelper/MouseHelper/ClipboardHelper), project/csproj scaffolding, naming rules, common PowerToys test recipes (toggle a module, read an activation shortcut, fire a global hotkey, inspect the clipboard, discover overlay/editor windows), and build/run validation. Keywords: UI test, UITests, UITestAutomation, UITestAutomation.Next, winappcli, winapp.exe, WinAppDriver, Selenium, Appium, migrate, port, convert, modernize, .Next, end-to-end, E2E, MSTest."
|
||||
license: Complete terms in LICENSE.txt
|
||||
---
|
||||
|
||||
# PowerToys UI-Tests Migration (legacy → `.Next`)
|
||||
|
||||
Convert a PowerToys module's UI tests from the legacy **WinAppDriver / Selenium / Appium** harness
|
||||
(`Microsoft.PowerToys.UITest`, in `src/common/UITestAutomation/`) to the new **winappcli** harness
|
||||
(`Microsoft.PowerToys.UITest.Next`, in `src/common/UITestAutomation.Next/`).
|
||||
|
||||
The new harness shells out to `winapp.exe` and parses its JSON — **no WinAppDriver server on :4723,
|
||||
no Selenium/Appium NuGet packages, no `WindowsElement`/`WindowsDriver`.** The public *shape*
|
||||
(`UITestBase`, `Session`, `Find<T>`, `By`, element wrappers like `ToggleSwitch`) is deliberately
|
||||
similar, so most of the work is mechanical API mapping plus reworking a few patterns that don't
|
||||
translate one-to-one (XPath selectors, stateful elements, instance mouse/keyboard helpers).
|
||||
|
||||
## When to use this skill
|
||||
|
||||
Use this skill when the task is to:
|
||||
|
||||
- **Port** a module's existing legacy UI tests to `.Next` (e.g. "migrate the ScreenRuler UI tests to
|
||||
the new framework", "convert FancyZones.UITests to winappcli").
|
||||
- **Create a new** `[Module].UITests.Next` project that re-implements the legacy tests with the new
|
||||
harness, leaving the old project in place.
|
||||
- **Stand up brand-new** `.Next` UI tests for a module that has **no** UI tests at all, by reading the
|
||||
module's human test **sign-off markdown** (e.g. `ColorPickerUITest.md`) and turning each manual
|
||||
checklist item into an automated test.
|
||||
|
||||
This skill is the *how*: the framework differences, the API mapping, the project scaffolding, the
|
||||
naming rules, the recurring PowerToys test recipes, and the build/validate loop. The *what* (which
|
||||
module, which tests) comes from the calling prompt.
|
||||
|
||||
> **Reference implementation — read these working examples before porting anything.** They are
|
||||
> the ground truth for "what good looks like" with each harness:
|
||||
> - **New (`.Next`)**: [ColorPickerEndToEndTests.cs](../../../src/modules/colorPicker/ColorPicker.UITests/ColorPickerEndToEndTests.cs)
|
||||
> — full end-to-end scenario (navigate Settings → toggle module → read shortcut → fire hotkey →
|
||||
> read overlay → click-capture → inspect editor), driven entirely through `winappcli`.
|
||||
> - **Legacy**: [TestSpacing.cs](../../../src/modules/MeasureTool/Tests/ScreenRuler.UITests/TestSpacing.cs)
|
||||
> + [TestHelper.cs](../../../src/modules/MeasureTool/Tests/ScreenRuler.UITests/TestHelper.cs)
|
||||
> — a `UITestBase` subclass plus a static helper that navigates, toggles, reads the shortcut, fires
|
||||
> the hotkey, and validates the clipboard.
|
||||
> - **Worked Scenario-A port (validated 5/5, where the legacy suite scored 0/5 locally)**: the
|
||||
> ScreenRuler suite ported from the legacy project above lives in
|
||||
> [ScreenRuler.UITests.Next/TestHelper.cs](../../../src/modules/MeasureTool/Tests/ScreenRuler.UITests.Next/TestHelper.cs)
|
||||
> + 5 test classes. It is the canonical port reference — cross-window toolbar discovery via
|
||||
> `Session.FromProcess`, a DPI-aware `app.manifest`, cursor centering, and patient hotkey
|
||||
> activation are all there because real runs needed them (see
|
||||
> [references/patterns-and-pitfalls.md](references/patterns-and-pitfalls.md)).
|
||||
|
||||
## Required reads (in order)
|
||||
|
||||
1. **This `SKILL.md`** — the decision tree (which scenario), the naming rules, the high-level
|
||||
workflow, and the build/validate loop.
|
||||
2. **[references/framework-differences.md](references/framework-differences.md)** — the conceptual
|
||||
deltas you MUST internalize before writing code: winappcli engine, stateless elements, selector
|
||||
grammar (no XPath/CssSelector), session scopes (window vs process), lifecycle/hygiene/module
|
||||
pre-enablement, multi-window discovery, and what the new harness does NOT (yet) provide.
|
||||
3. **[references/api-mapping.md](references/api-mapping.md)** — the line-by-line cheat sheet:
|
||||
namespaces, `By`, `Element` actions/properties, `Session`, `UITestBase`, the static
|
||||
Keyboard/Mouse/Clipboard helpers, and the element-wrapper catalog. Keep this open while editing.
|
||||
4. **[references/project-setup.md](references/project-setup.md)** — csproj scaffold, naming/placement
|
||||
rules, `.slnx` registration, and how to build & run a `.Next` project. Uses the
|
||||
[templates/](templates/) starter files.
|
||||
5. **[references/porting-workflow.md](references/porting-workflow.md)** — the two end-to-end
|
||||
playbooks: **A)** port existing legacy tests, and **B)** author tests from a human sign-off
|
||||
markdown when none exist.
|
||||
6. **[references/patterns-and-pitfalls.md](references/patterns-and-pitfalls.md)** — adaptable recipes
|
||||
for the recurring PowerToys patterns (toggle a module + verify its process, read the activation
|
||||
shortcut from a `ShortcutControl`, fire a global hotkey reliably, inspect the clipboard, discover
|
||||
overlay/editor windows) and the gotchas that bite during migration.
|
||||
|
||||
## Pick your scenario
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Module to migrate] --> B{Does a legacy<br/>UITests project exist?}
|
||||
B -- Yes --> C["Scenario A: PORT<br/>Create [Module].UITests.Next<br/>Re-implement each legacy test"]
|
||||
B -- No --> D{Is there a human test<br/>sign-off .md?}
|
||||
D -- Yes --> E["Scenario B: GREENFIELD<br/>Create [Module].UITests<br/>Turn each checklist item into a test"]
|
||||
D -- No --> F[Ask the user for the<br/>test spec / sign-off doc]
|
||||
```
|
||||
|
||||
| Scenario | Trigger | New project name | Source of test cases |
|
||||
|---|---|---|---|
|
||||
| **A — Port** | A legacy `[Module].UITests` (or similar) project already exists and references `UITestAutomation.csproj` | **`[Module].UITests.Next`** — keep the `.Next` suffix so it lives **alongside** the legacy project | The existing legacy test methods (1:1 re-implementation) |
|
||||
| **B — Greenfield** | The module has **no** UI tests at all | **`[Module].UITests`** — **drop** the `.Next` suffix; there's nothing to live alongside | The module's human sign-off markdown (manual checklist), e.g. `ColorPickerUITest.md` |
|
||||
|
||||
Place the new project under **`src/modules/[Module]/Tests/[Module].UITests.Next/`** (or
|
||||
`…/Tests/[Module].UITests/` for Scenario B). If the module already keeps tests in a different
|
||||
`Tests/` layout, match the module's existing convention rather than forcing this one — see
|
||||
[references/project-setup.md](references/project-setup.md).
|
||||
|
||||
> **Keep it abstract.** Every PowerToys module is unique and the legacy tests were written by
|
||||
> different people in different styles. Treat the recipes in this skill as *adaptable patterns*, not
|
||||
> a rigid script. Re-create the **intent and assertions** of each test; do not mechanically translate
|
||||
> brittle, harness-specific scaffolding (Selenium `Actions`, XPath walks, manual driver attaches) when
|
||||
> the new harness has a cleaner idiom.
|
||||
|
||||
## High-level workflow
|
||||
|
||||
Create a TODO list and work top-to-bottom. Each step links to the reference that drives it.
|
||||
|
||||
```markdown
|
||||
- [ ] 1. Identify the module + scenario (A port / B greenfield) — this SKILL.md "Pick your scenario"
|
||||
- [ ] 2. Read the two reference examples (ColorPicker .Next + ScreenRuler legacy) end-to-end
|
||||
- [ ] 3. Inventory the source:
|
||||
• Scenario A → list every [TestMethod] + shared helper in the legacy project
|
||||
• Scenario B → read the module's sign-off .md; list each manual checklist item
|
||||
— references/porting-workflow.md
|
||||
- [ ] 4. Internalize the deltas — references/framework-differences.md
|
||||
- [ ] 5. Scaffold the new project (csproj from template, name per the table, register in .slnx)
|
||||
— references/project-setup.md
|
||||
- [ ] 6. Re-implement tests, mapping each API as you go — references/api-mapping.md
|
||||
+ recipes from references/patterns-and-pitfalls.md
|
||||
- [ ] 7. Build the new project to exit code 0 — this SKILL.md "Build & validate"
|
||||
- [ ] 8. (If a live desktop is available) run the tests; otherwise report that they build and are
|
||||
ready to run, and summarize coverage vs. the source
|
||||
```
|
||||
|
||||
## Build & validate
|
||||
|
||||
The `.Next` harness needs `winapp.exe` only at **run** time, not build time — the project has zero
|
||||
managed dependency on the engine. So you can always compile-verify a migration even on an agent with
|
||||
no winappcli installed.
|
||||
|
||||
```pwsh
|
||||
# 0. FIRST build of a brand-new project: restore so the assets file exists, otherwise the build
|
||||
# fails with NETSDK1004 "Assets file ... project.assets.json not found".
|
||||
dotnet restore src\modules\<Module>\Tests\<Module>.UITests.Next\<Module>.UITests.Next.csproj -p:Platform=x64
|
||||
# (Equivalently, run tools\build\build-essentials.cmd once at the start of the session.)
|
||||
|
||||
# 1. Build just the new test project (fast inner loop). Prefer the repo build script.
|
||||
tools\build\build.cmd -Path src\modules\<Module>\Tests\<Module>.UITests.Next -Platform x64 -Configuration Debug
|
||||
# Exit code 0 = success; non-zero = failure. On failure read the errors log next to the project:
|
||||
# build.<Configuration>.<Platform>.errors.log
|
||||
|
||||
# 2. Run (needs a live desktop). A .Next project is a Microsoft.Testing.Platform Exe — run the
|
||||
# produced exe directly with a TRX report; filter to one test/category for a tight loop.
|
||||
$exe = "<repo>\x64\Debug\tests\<Module>.UITests.Next\net10.0-windows10.0.26100.0\<Module>.UITests.Next.exe"
|
||||
& $exe --filter "TestCategory=<Cat>" --report-trx --report-trx-filename run.trx --results-directory <dir>
|
||||
# --filter accepts "TestCategory=X" or "FullyQualifiedName~Y"; omit it to run everything.
|
||||
# Exit 0 = all passed. Parse the .trx for per-test outcomes + failure messages.
|
||||
```
|
||||
|
||||
- **Run it in a loop: write → build → run → diagnose → repeat.** UI tests surface environment-real
|
||||
failures (DPI scaling, cursor position, hotkey-arming races) that only a live run reveals. Start
|
||||
with one deterministic test (e.g. the activation/toggle test), get it green, then widen.
|
||||
- **First, run the *legacy* suite once for a baseline — and run it ELEVATED.** The legacy harness
|
||||
launches PowerToys via `ProcessStartInfo { Verb = "runas" }` (elevated), so a **non-elevated** test
|
||||
host can't complete the launch and **every test fails at startup with a misleading `Win32Exception`
|
||||
cascade** — a false 0/N that looks like "the tests are broken" but is purely the run method. (That's
|
||||
why VS Test Explorer passes them: VS runs as admin.) Run from an **elevated** terminal: start
|
||||
`WinAppDriver.exe` on `127.0.0.1:4723`, then run the built DLL with `vstest.console.exe` (see
|
||||
[references/porting-workflow.md](references/porting-workflow.md) §A0 for the `-Verb RunAs` recipe).
|
||||
A measurement failure on a scaled (non-100%) display is usually a pre-existing DPI issue (Pitfall
|
||||
12), not something the port must reproduce — the ScreenRuler legacy suite scores **4/5** elevated
|
||||
here (Bounds fails at 150% scale) while the `.Next` port scores **5/5**. `.Next` tests themselves
|
||||
need **no** elevation (the new harness launches the runner non-elevated).
|
||||
- **Always** build to exit code 0 before declaring the migration done. Fix every compile error — do
|
||||
not leave `// TODO: port this` stubs that break the build.
|
||||
- Running the tests requires a **live interactive desktop** plus `winapp.exe`
|
||||
(`winget install Microsoft.winappcli`, or set `WINAPP_CLI_PATH`). The whole PowerToys runner is
|
||||
launched by the harness (`PowerToys.exe --open-settings`) — you should see the Settings window
|
||||
appear. If the environment has no desktop (headless agent), state that the project **builds clean
|
||||
and is ready to run**, and list which source tests/checklist items each new `[TestMethod]` covers.
|
||||
- New `.csproj` files under `src/` MUST `<Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" />`
|
||||
right after `<Project Sdk=...>` (CI audits this). The template already does.
|
||||
|
||||
## What NOT to do
|
||||
|
||||
- **Do NOT delete or edit the legacy `[Module].UITests` project** in Scenario A. The `.Next` project
|
||||
lives alongside it; removing the old one is a separate, explicit decision for the maintainers.
|
||||
- **Do NOT touch product code.** This is a test-only migration. If a test needs a UIA hook that
|
||||
doesn't exist (e.g. an `AutomationId` or a hidden automation-peer TextBlock), flag it for the user
|
||||
rather than silently editing the module. (The ColorPicker example's `ColorHexAutomationPeer` hook
|
||||
is a documented, pre-existing exception — see its class remarks.)
|
||||
- **Do NOT port the legacy plumbing literally.** No Selenium `Actions`, no `WindowsDriver`/`WindowsElement`,
|
||||
no `By.XPath`/`By.CssSelector`, no `:4723`. Map them to the winappcli idioms in
|
||||
[references/api-mapping.md](references/api-mapping.md).
|
||||
- **Do NOT add a `ProjectReference` to `UITestAutomation.csproj`** (the legacy harness) — reference
|
||||
**`UITestAutomation.Next.csproj`** only.
|
||||
- **Do NOT invent assertions** for a vague sign-off item. If a checklist line has no observable
|
||||
pass/fail signal, implement what you can and leave a clearly-marked `TestContext.WriteLine` note
|
||||
(or skip with an explanation) rather than asserting on something you can't actually read.
|
||||
- **Do NOT introduce new third-party NuGet dependencies.** The `.Next` harness is intentionally
|
||||
dependency-free (MSTest only). Use the Win32-based helpers it already ships.
|
||||
|
||||
## What is NICE to do
|
||||
|
||||
- **Improve the new UT Test framework if you see such opportunity**. The new framework works only with a few modules and may lack something other requires. If you see the old test uses something that we don't have in a new framework and it's handy, don't hesiate to port it to a new one. Or you may see the test uses a bunch of extra helpers ouside of test framework, which also may be a signal.
|
||||
171
.github/skills/ui-tests-migration/references/api-mapping.md
vendored
Normal file
171
.github/skills/ui-tests-migration/references/api-mapping.md
vendored
Normal file
@@ -0,0 +1,171 @@
|
||||
# API mapping cheat sheet (legacy → `.Next`)
|
||||
|
||||
Keep this open while editing. Left column is the legacy `Microsoft.PowerToys.UITest` API; right column
|
||||
is the `Microsoft.PowerToys.UITest.Next` equivalent. "—" means no direct member; see the Notes.
|
||||
|
||||
## Namespaces & usings
|
||||
|
||||
| Legacy | `.Next` |
|
||||
|---|---|
|
||||
| `using Microsoft.PowerToys.UITest;` | `using Microsoft.PowerToys.UITest.Next;` |
|
||||
| `using OpenQA.Selenium;` / `…Appium…` | *(delete — no Selenium/Appium)* |
|
||||
| `[TestClass] : UITestBase` | `[TestClass] : UITestBase` *(same shape; different namespace)* |
|
||||
| `using Microsoft.VisualStudio.TestTools.UnitTesting;` | *(unchanged)* |
|
||||
|
||||
## `UITestBase` (the base class)
|
||||
|
||||
| Legacy | `.Next` | Notes |
|
||||
|---|---|---|
|
||||
| `: base(PowerToysModule.PowerToysSettings)` | `: base(PowerToysModule.PowerToysSettings)` | Same enum name; **values differ** — see enum table below. |
|
||||
| `: base(scope, WindowSize.Large)` | `: base(scope, WindowSize.Large)` | Same `WindowSize` enum. |
|
||||
| `: base(scope, size, commandLineArgs: new[]{…})` | `: base(scope, size, enableModules: new[]{…})` | 3rd arg changed from launch args to a deterministic module-enable list. |
|
||||
| `Session` (property) | `Session` (property) | Same name. Legacy is `required set`; `.Next` is `private set` (assigned by `TestInit`). |
|
||||
| `Find<T>(by, timeoutMS, global)` | `Find<T>(by, timeoutMS)` | No `global` param (see framework-differences §4). |
|
||||
| `Find(name)` / `Find<T>(name)` | `Find(name)` / `Find<T>(name)` | Same. |
|
||||
| `Has<T>/HasOne<T>(by, …, global)` | `Has<T>/HasOne<T>(by, …)` | No `global`. |
|
||||
| `FindByPartialName<T>(s)` | `Find<T>(By.Name(s))` | winappcli `By.Name` is already a substring match. |
|
||||
| `FindByPattern<T>(regex)` | `Session.FindAll<T>(By.Name(...))` + C# `Regex` | No base helper; filter in C#. |
|
||||
| `FindByClassName<T>(c)` | `Find<T>(By.Name(...))` with a typed wrapper | Wrappers pin ClassName; or `FindAll` + filter on `.ClassName`. |
|
||||
| `SendKeys(Key[])` / `SendKeySequence(Key[])` | `KeyboardHelper.SendKeys(Key[])` | Static helper (also `Session.SendKeys` passthrough). |
|
||||
| `MoveMouseTo(x,y)` | `MouseHelper.MoveTo(x,y)` | Static helper. |
|
||||
| `GetMousePosition()` → `(int,int)` | `MouseHelper.GetMousePosition()` → `(int X,int Y)` | Static helper. |
|
||||
| `IsWindowOpen(name)` | `WindowsFinder.ListByApp(proc).Count > 0` | Or `SessionHelper.IsRunning(scope)` for a process check. |
|
||||
| `RestartScopeExe(enableModules?)` | `RestartScope(enableModules?)` | Returns the fresh `Session`. |
|
||||
| `ExitScopeExe()` | *(automatic)* `sessionHelper.StopIfStarted()` in `TestCleanup` | Rarely needed manually. |
|
||||
|
||||
## `PowerToysModule` enum (values differ!)
|
||||
|
||||
| Legacy value | `.Next` value | Notes |
|
||||
|---|---|---|
|
||||
| `PowerToysSettings` | `PowerToysSettings` | Same. The default; drive most modules through it. |
|
||||
| `FancyZone` | `FancyZonesEditor` | **Renamed.** |
|
||||
| `Hosts` | `Hosts` | Same. |
|
||||
| `Runner` | `Runner` | Same. |
|
||||
| `Workspaces` | `Workspaces` | Same. |
|
||||
| `PowerRename` | `PowerRename` | Same. |
|
||||
| `CommandPalette` | `CommandPalette` | Same. |
|
||||
| `ScreenRuler` | `ScreenRuler` | Same. |
|
||||
| `LightSwitch` | `LightSwitch` | Same. |
|
||||
| *(n/a)* | `ColorPicker` | New entry (overlay module — drive via the Settings scope). |
|
||||
|
||||
## `By` selectors
|
||||
|
||||
| Legacy | `.Next` | Notes |
|
||||
|---|---|---|
|
||||
| `By.Name("x")` | `By.Name("x")` | winappcli = case-insensitive **substring** over Name/AutomationId. |
|
||||
| `By.AccessibilityId("Id")` | `By.AccessibilityId("Id")` | **Preferred.** Also `By.Id("Id")`. |
|
||||
| `By.Id("Id")` | `By.Id("Id")` / `By.AccessibilityId("Id")` | Same intent. |
|
||||
| `By.ClassName("C")` | *(none)* | Use a typed wrapper, or `FindAll` + filter on `.ClassName`. |
|
||||
| `By.XPath("//*[contains(@Name,'x')]")` | `By.Name("x")` | Substring search covers `contains(@Name)`. |
|
||||
| `By.XPath("//*[@Name='x']")` | `By.Name("x")` (+ C# exact filter if needed) | |
|
||||
| `By.XPath` (structural axes) | scoped `element.Find<T>(By.…)` or `FindAll` + C# filter | No XPath engine. |
|
||||
| `By.CssSelector(...)` | *(none)* | Re-express as above. |
|
||||
| *(n/a)* | `By.Slug("btn-x-1a2b")` | Direct slug from `inspect`/`search` output. |
|
||||
|
||||
## `Element` — properties
|
||||
|
||||
| Legacy | `.Next` | Notes |
|
||||
|---|---|---|
|
||||
| `Name` | `Name` | `.Next` is cached at Find time; re-find for fresh. |
|
||||
| `ClassName` | `ClassName` | Cached. |
|
||||
| `ControlType` | `ControlType` | Cached. |
|
||||
| `Text` | `GetValue()` | TextPattern→ValuePattern→Selection→Name fallback. |
|
||||
| `Enabled` | `IsEnabled` | Live read via `get-property`. |
|
||||
| `Displayed` | `Displayed` (== `!IsOffscreen`) | Live read. |
|
||||
| `Selected` | `Selected` | Live read (`IsSelected`). |
|
||||
| `AutomationId` | `AutomationId` | Live read. |
|
||||
| `HelpText` | `HelpText` | Live read (used for `ShortcutControl` text). |
|
||||
| `Rect` → `Rectangle?` | `X`, `Y`, `Width`, `Height` (ints) | Cached snapshot; re-find if UI moved. |
|
||||
| `GetAttribute("P")` | `GetAttribute("P")` / `GetProperty("P")` | Both live-read one UIA property. |
|
||||
|
||||
## `Element` — actions
|
||||
|
||||
| Legacy | `.Next` | Notes |
|
||||
|---|---|---|
|
||||
| `Click(rightClick=false, msPreAction=500, msPostAction=500)` | `Click(rightClick=false, msPostAction=200)` | **No `msPreAction`.** Uses UIA invoke (falls back to toggle/select/expand); `rightClick` → `click --right`. Add an explicit `Thread.Sleep` before if you relied on `msPreAction`. |
|
||||
| `Click()` on a non-invokable element (TextBlock/ListItem) | `MouseClick(msPostAction=200)` | Real mouse simulation — use when the click is handled by an ancestor (the ColorPicker utility-stack label pattern). |
|
||||
| `DoubleClick()` | `DoubleClick(msPostAction=200)` | Real mouse double-click. |
|
||||
| Selenium `Actions` drag | `Drag(offsetX, offsetY, steps=10)` / `DragTo(target)` | Win32 mouse; uses cached center. |
|
||||
| `Actions` key-down + drag | `KeyDownAndDrag(key, targetX, targetY, steps)` | Modifier-drag (FancyZones merge, tab tear-off). |
|
||||
| `ReleaseKey(key)` | `KeyboardHelper.ReleaseKey(key)` | |
|
||||
| `SetText`/`Clear`+`SendKeys` (TextBox) | `TextBox.SetText("v")` | `winapp ui set-value`. |
|
||||
| `element.Find<T>(by)` | `element.Find<T>(by)` | Scoped search under the element. |
|
||||
| `ScrollIntoView()` | `ScrollIntoView()` | Same. |
|
||||
| — | `Scroll(ScrollDirection)`, `ScrollToEdge(toBottom)` | New scroll verbs. |
|
||||
| — | `Focus()` | `winapp ui focus`. |
|
||||
| — | `WaitForProperty(p, v, t)`, `WaitForValue(v, contains, t)`, `WaitForGone(t)` | Built-in waits (replace manual poll loops). |
|
||||
|
||||
## `Session`
|
||||
|
||||
| Legacy | `.Next` | Notes |
|
||||
|---|---|---|
|
||||
| `Find<T>(by, t, global)` / `Find(name)` | `Find<T>(by, t)` / `Find(name)` | No `global`. |
|
||||
| `FindAll<T>(by, t, global)` | `FindAll<T>(by, t)` | No `global`; polls until found or timeout. |
|
||||
| `Has`/`HasOne`/`Has<T>` | `Has`/`HasOne<T>`/`Has<T>` | Same intent. |
|
||||
| `Attach(PowerToysModule)` / `Attach(windowName)` | `Session.Attach(module, size?)` / `Session.FromProcess(app)` / `WindowsFinder.WaitForWindowByApp(...)` | Re-bind to another window/process. |
|
||||
| `SendKeys(Key[])` / `SendKey(key, …)` | `Session.SendKeys(Key[])` or `KeyboardHelper.SendKeys(Key[])` | Prefer the static helper. |
|
||||
| `MoveMouseTo(x,y, …)` | `MouseHelper.MoveTo(x,y)` | Static. |
|
||||
| `PerformMouseAction(MouseActionType.LeftClick)` | `MouseHelper.LeftClick()` | See action map below. |
|
||||
| `SetMainWindowSize(size)` | `WindowHelper.SetWindowSize(hwnd, size)` | `hwnd = new IntPtr(Session.WindowHandle)`. |
|
||||
| `MainWindowHandler` (`IntPtr`) | `WindowHandle` (`long`) / `WindowHandleArg` (string) | |
|
||||
| — | `Inspect(depth, interactive, …)` → `JsonElement` | `winapp ui inspect --json` tree (the ColorPicker editor walk). |
|
||||
| — | `WaitForElement(by, t)`, `WaitFor(Func<bool>, t)` | Built-in waits. |
|
||||
| — | `Screenshot(path, element?, captureScreen?)` / `TryScreenshot(...)` | |
|
||||
|
||||
### `MouseActionType` → `MouseHelper`
|
||||
|
||||
| Legacy `PerformMouseAction(...)` | `.Next` |
|
||||
|---|---|
|
||||
| `MouseActionType.LeftClick` | `MouseHelper.LeftClick()` |
|
||||
| `MouseActionType.RightClick` | `MouseHelper.RightClick()` |
|
||||
| `MouseActionType.LeftDown` / `LeftUp` | `MouseHelper.LeftDown()` / `LeftUp()` |
|
||||
| `MouseActionType.RightDown` / `RightUp` | `MouseHelper.RightDown()` / `RightUp()` |
|
||||
| (scroll) | `MouseHelper.ScrollUp()` / `ScrollDown()` / `ScrollWheel(amount)` |
|
||||
| (drag) | `MouseHelper.Drag(fromX, fromY, toX, toY, steps)` |
|
||||
|
||||
## Static helpers (new — no instance equivalent)
|
||||
|
||||
| Need | `.Next` helper |
|
||||
|---|---|
|
||||
| Send a key chord (incl. global Win-key hotkeys) | `KeyboardHelper.SendKeys(Key.LWin, Key.Shift, Key.C)` |
|
||||
| Hold/release a key | `KeyboardHelper.PressKey(key)` / `KeyboardHelper.ReleaseKey(key)` |
|
||||
| Move cursor / read cursor | `MouseHelper.MoveTo(x,y)` / `MouseHelper.GetMousePosition()` |
|
||||
| Click at the current/again a point | `MouseHelper.LeftClick()` / `LeftClickAt(x,y)` / `RightClick()` / `DoubleClick()` |
|
||||
| Read clipboard | `ClipboardHelper.GetText()` |
|
||||
| Clear clipboard | `ClipboardHelper.Clear()` |
|
||||
| Set clipboard | `ClipboardHelper.SetText("v")` |
|
||||
| Wait for clipboard to change | `ClipboardHelper.WaitForText(ignoredValue, timeoutMS)` |
|
||||
| Seed module on/off baseline | `SettingsConfigHelper.ConfigureGlobalModuleSettings("ColorPicker", …)` |
|
||||
| Edit a module's own settings.json | `SettingsConfigHelper.UpdateModuleSettings(name, default, json => {…})` |
|
||||
|
||||
> The legacy `TestHelper.ClearClipboard`/`GetClipboardText` STA-thread wrappers are replaced by
|
||||
> `ClipboardHelper` (which already runs on an STA thread internally). Delete the hand-rolled STA code.
|
||||
|
||||
## Element wrappers (`Find<T>`)
|
||||
|
||||
| Wrapper | Legacy | `.Next` | Notes |
|
||||
|---|---|---|---|
|
||||
| `Element` | ✅ | ✅ | Base. |
|
||||
| `Button` | ✅ | ✅ | |
|
||||
| `CheckBox` | ✅ | ✅ | |
|
||||
| `ComboBox` | ✅ | ✅ | `.Select(item)` / `.SelectByText(text)` / `.SelectedText`. |
|
||||
| `RadioButton` | ✅ | ✅ | |
|
||||
| `Slider` | ✅ | ✅ | |
|
||||
| `Tab` | ✅ | ✅ | |
|
||||
| `TextBlock` | ✅ | ✅ | |
|
||||
| `TextBox` | ✅ | ✅ | `.SetText(v)` / `.Value`. |
|
||||
| `ToggleSwitch` | ✅ | ✅ | `.IsOn` / `.Toggle(bool)`. Pins `ClassName="ToggleSwitch"`. |
|
||||
| `Thumb` | ✅ | ✅ | |
|
||||
| `NavigationViewItem` | ✅ | ✅ | UIA `ListItem`. |
|
||||
| `Pane` | ✅ | ✅ | |
|
||||
| `Custom` | ✅ | ✅ | UIA `Custom` (FancyZones zones, Workspaces canvas). |
|
||||
| `Window` | ✅ | ✅ | |
|
||||
| `Group` | ✅ | ❌ | Use `Find<Element>` or add a wrapper. |
|
||||
| `HyperlinkButton` | ✅ | ❌ | Use `Find<Button>` (it's a Button under UIA) or add a wrapper. |
|
||||
|
||||
## `Key` enum
|
||||
|
||||
Both frameworks expose a `Key` enum. The `.Next` `Key` (in `KeyboardHelper.cs`) uses `LWin` (not
|
||||
`Win`). When porting a shortcut parser, map `"win"`/`"windows"` → `Key.LWin`. Letters `A`–`Z`,
|
||||
digits `Num0`–`Num9`, `F1`–`F12`, and the usual `Ctrl/Shift/Alt/Esc/Enter/Tab/Space/Arrows` are all
|
||||
present.
|
||||
167
.github/skills/ui-tests-migration/references/framework-differences.md
vendored
Normal file
167
.github/skills/ui-tests-migration/references/framework-differences.md
vendored
Normal file
@@ -0,0 +1,167 @@
|
||||
# Framework differences: legacy vs `.Next`
|
||||
|
||||
The conceptual deltas you must internalize before porting. Read this once, end-to-end, then keep
|
||||
[api-mapping.md](api-mapping.md) open for the mechanical lookups.
|
||||
|
||||
## At a glance
|
||||
|
||||
| Aspect | Legacy `Microsoft.PowerToys.UITest` | New `Microsoft.PowerToys.UITest.Next` |
|
||||
|---|---|---|
|
||||
| Folder | `src/common/UITestAutomation/` | `src/common/UITestAutomation.Next/` |
|
||||
| Namespace | `Microsoft.PowerToys.UITest` | `Microsoft.PowerToys.UITest.Next` |
|
||||
| Assembly | `Microsoft.PowerToys.UITest` | `Microsoft.PowerToys.UITest.Next` |
|
||||
| Engine | WinAppDriver server on `http://127.0.0.1:4723` + Selenium/Appium | `winapp.exe` CLI (shell out, parse `--json`) |
|
||||
| Driver object | `WindowsDriver<WindowsElement>`, `WindowsElement` | none — every call is a `winapp ui …` subprocess |
|
||||
| 3rd-party deps | `Appium.WebDriver`, `Selenium.WebDriver`, … | none (MSTest only) |
|
||||
| Element model | **stateful** — wraps a live `WindowsElement` | **stateless** — wraps a selector; every read/action re-shells out |
|
||||
| Selector grammar | Selenium `By` (Name, ClassName, Id, **XPath**, **CssSelector**, AccessibilityId) | winappcli `By` (**Name=text**, **AccessibilityId**, **Slug**) — no XPath/CSS |
|
||||
| Find scope flag | `bool global` parameter on every `Find` | no `global` param — session scope (`-w`/`-a`) decides reach |
|
||||
| Mouse/keyboard | instance methods on `Session`/`UITestBase` (`MoveMouseTo`, `PerformMouseAction`, `SendKeys`) | **static** helpers (`MouseHelper`, `KeyboardHelper`, `ClipboardHelper`) |
|
||||
| Run-time prereq | WinAppDriver installed + running | `winapp.exe` on PATH (or `WINAPP_CLI_PATH`) |
|
||||
| Elevation | **Required** — harness launches the runner via `Verb="runas"`; a non-elevated host fails at launch | **Not required** — harness launches the runner non-elevated (works from a plain terminal) |
|
||||
| Test runner | MSTest (VSTest) | MSTest via Microsoft.Testing.Platform (`EnableMSTestRunner`) |
|
||||
|
||||
## 1. The engine: winappcli, not WinAppDriver
|
||||
|
||||
The legacy harness spins up a WinAppDriver server and talks Selenium WebDriver to it. The `.Next`
|
||||
harness has **no server and no session protocol** — `WinappCli.Invoke(...)` starts `winapp.exe`,
|
||||
captures stdout/stderr/exit-code, and (for `--json` verbs) parses the envelope. Every `Find`,
|
||||
property read, click, and key press is an independent process invocation.
|
||||
|
||||
Consequences you'll feel while porting:
|
||||
|
||||
- There is no long-lived "driver" to attach/dispose. `Session` is a lightweight value object holding a
|
||||
target flag (`-w <hwnd>` or `-a <app>`) and metadata. `Session.Cleanup()` is a no-op.
|
||||
- "Is the CLI installed?" is checked once per run (`WinappCli.IsAvailable()` from `UITestBase`), and a
|
||||
missing CLI fails fast with an install hint — you don't manage that.
|
||||
- Errors surface as non-zero exit codes + stderr, wrapped into MSTest `Assert` failures with a
|
||||
`winapp … -> exit N; stderr: …` description. There are no `WebDriverException`/`NoSuchElementException`
|
||||
types to catch — use the `Has*`/`WaitFor*` probes instead of try/catch on Find.
|
||||
|
||||
## 2. Elements are stateless
|
||||
|
||||
Legacy `Element` wraps a live `WindowsElement`; properties like `Enabled`, `Text`, `Rect` read the
|
||||
cached Selenium object. `.Next` `Element` wraps **only a selector** (a winappcli slug or text query)
|
||||
plus the owning `Session`. The `ControlType`, `ClassName`, `Name`, `X/Y/Width/Height` fields are the
|
||||
values captured **at `Find` time**; every *fresh* read (`IsEnabled`, `GetProperty(...)`, `GetValue()`)
|
||||
shells out again via `winapp ui get-property`/`get-value`.
|
||||
|
||||
Porting implications:
|
||||
|
||||
- Cached geometry (`X`, `Y`, `Width`, `Height`) is a **snapshot**. If the UI moved since `Find`,
|
||||
re-find before using coordinates for a `Drag`/`MouseClick`.
|
||||
- There is no `element.Rect` returning a live `Rectangle`. Use the cached `X/Y/Width/Height` ints, or
|
||||
re-find.
|
||||
- Don't hold an `Element` across a navigation/relaunch and expect it to still resolve — re-find after
|
||||
the tree changes.
|
||||
|
||||
## 3. Selectors: `By.Name` / `By.AccessibilityId` / `By.Slug` only
|
||||
|
||||
The new `By` (in `By.cs`) is **not** Selenium's `By`. It has three kinds:
|
||||
|
||||
| `.Next` factory | Meaning | winappcli mechanic |
|
||||
|---|---|---|
|
||||
| `By.Name(text)` | case-insensitive substring search over Name/AutomationId | `winapp ui search "<text>"` |
|
||||
| `By.AccessibilityId(id)` / `By.Id(id)` | stable `AutomationId` | search by id |
|
||||
| `By.Slug(slug)` | a semantic slug printed by `inspect`/`search` (e.g. `btn-close-d1a0`) | direct slug selector |
|
||||
|
||||
There is **no** `By.XPath`, `By.ClassName`, or `By.CssSelector`. To port those:
|
||||
|
||||
- `By.ClassName("ToggleSwitch")` → use the typed wrapper (`Find<ToggleSwitch>(By.Name(...))`), which
|
||||
pins `ClassName` via `TargetClassName`. The wrapper's class filter replaces the ClassName selector.
|
||||
- `By.XPath("//*[contains(@Name,'foo')]")` (the legacy `FindByPartialName`) → `By.Name("foo")` already
|
||||
does substring matching in winappcli, so a partial-name XPath usually collapses to a plain
|
||||
`By.Name`.
|
||||
- `By.XPath("//*[@Name='exact']")` → `By.Name("exact")` (winappcli substring-matches; if you need to
|
||||
disambiguate, `FindAll` then filter in C# on `m.Name == "exact"`).
|
||||
- Complex structural XPath (parent/child axes) → there is no direct equivalent. Re-express as: find the
|
||||
container by id, then `container.Find<T>(By.…)` (scoped search), or `Session.FindAll<T>` + a C#
|
||||
`Where(...)` on the cached `ControlType`/`ClassName`/`Name`/coordinates. The ColorPicker example
|
||||
does exactly this (`FindAll<Element>(By.Name("Color Picker"))` then `.OrderByDescending(m => m.X)`).
|
||||
|
||||
**Prefer `By.AccessibilityId`.** When porting, if a legacy test used a fragile `By.Name` or XPath, check
|
||||
the module's XAML for an `x:Name`/`AutomationProperties.AutomationId` and switch to `By.AccessibilityId`
|
||||
— it's the most stable selector and what the new examples favor.
|
||||
|
||||
## 4. No `global` parameter — session scope decides reach
|
||||
|
||||
Legacy `Find<T>(by, timeoutMS, global)` had a `global` bool to widen the search beyond the current
|
||||
window. `.Next` `Find<T>(by, timeoutMS)` has **no** `global` param. Instead, the **session scope**
|
||||
governs reach:
|
||||
|
||||
- **Window scope** (`-w <hwnd>`, the default from `UITestBase`/`SessionHelper.Init`): searches within
|
||||
one window. Use when a process owns several windows and you must pin one (Settings vs. its
|
||||
`PopupHost`; ColorPicker overlay vs. editor).
|
||||
- **Process scope** (`-a <name|pid>`, via `Session.FromProcess(...)`): searches all of a process's
|
||||
windows; every call re-resolves, so it transparently survives window replacement (re-navigation,
|
||||
page swaps, dropdown popups in a separate `PopupHost`). Closest analog to the legacy `global: true`.
|
||||
|
||||
To reach a **different** window (e.g. an editor/overlay the module just spawned), don't pass a flag —
|
||||
discover it with `WindowsFinder`/`WindowControl` (see §6) and get a new `Session` bound to it.
|
||||
|
||||
## 5. Lifecycle, hygiene, and module pre-enablement (`UITestBase`)
|
||||
|
||||
Both bases run `[TestInitialize]`/`[TestCleanup]`, but the `.Next` base centralizes things the legacy
|
||||
tests often did by hand:
|
||||
|
||||
- **Constructor:** `UITestBase(PowerToysModule scope = PowerToysSettings, WindowSize size = UnSpecified, string[]? enableModules = null)`.
|
||||
- `scope` — which module/window to drive. **Most module tests use `PowerToysModule.PowerToysSettings`**
|
||||
and drive the utility *through* the Settings UI + its activation hotkey, because the **runner**
|
||||
(`PowerToys.exe`) owns module toggles and the centralized keyboard hook. Launching a module's UI
|
||||
exe standalone bypasses that and the hotkey never fires.
|
||||
- `size` — applied after the window appears; `UnSpecified` maximizes (deterministic on CI). Maps to
|
||||
the legacy `WindowSize` ctor arg.
|
||||
- `enableModules` — when non-null, exactly these modules are enabled (others disabled) in the global
|
||||
`settings.json` **before** launch. This is the deterministic replacement for the legacy
|
||||
`commandLineArgs`/`StartExe(enableModules)` pattern. The names are the keys under `"enabled"` (e.g.
|
||||
`"ColorPicker"`, `"FancyZones"`, `"Measure Tool"`).
|
||||
- **Pre-test hygiene** runs automatically: `Win+M` (minimize all) → `Esc` → kill stale PowerToys
|
||||
processes (`StaleProcessNames`, overridable). You usually delete the legacy test's manual
|
||||
`CloseOtherApplications`/`Win+M` calls.
|
||||
- **Teardown** stops only what the base launched (`StopIfStarted()`), so you rarely need a manual
|
||||
process-kill in `[TestCleanup]`. (Per-test cleanup of *spawned* windows — an overlay/editor the test
|
||||
popped — is still the test's job; use `WindowControl.TryCloseByApp` in a `finally`.)
|
||||
- **`RestartScope(enableModules?)`** replaces the legacy `RestartScopeExe` — re-seeds modules,
|
||||
kills + relaunches, reapplies size, returns the fresh `Session`.
|
||||
- **Class-shared window:** override `protected bool ReuseScopeAcrossTests => true;` to launch once per
|
||||
class and reuse the window across `[TestMethod]`s (skips per-test hygiene/relaunch). Use for smoke
|
||||
suites with many cheap cases against one window. Default is per-test isolation.
|
||||
|
||||
## 6. Multi-window discovery
|
||||
|
||||
The legacy harness used `Session.Attach(module|windowName)` to switch the driver to another window.
|
||||
`.Next` discovers windows with two static helpers:
|
||||
|
||||
- **`WindowsFinder`** (read/wait): `ListByApp(appNameOrPid)`, `ListAll()`,
|
||||
`WaitForWindowByApp(app, predicate, timeoutMS)`, `WaitForWindowByTitle(...)`,
|
||||
`WaitForWindowByProcess(...)`. Returns `WindowInfo` (hwnd/title/process/size/className) and, for the
|
||||
`WaitFor*` variants, a ready-to-use `Session` bound to that window. This is how the ColorPicker test
|
||||
finds the overlay (`Width<300 && Height<200`) vs. the editor (`Width>300 && Height>300`) from the
|
||||
same `PowerToys.ColorPickerUI` process.
|
||||
- **`WindowControl`** (tolerant cleanup): `TryCloseByApp(app[, predicate])`, `TryFocusByApp`,
|
||||
`TryKillProcessByName` (exact), `TryKillProcess` (substring), `SafeCloseAndFocus`. Every method
|
||||
swallows exceptions and returns a bool — designed for `finally` blocks so cleanup never masks the
|
||||
real failure.
|
||||
|
||||
Note: unfiltered `WindowsFinder.ListAll()` drops windows with no Win32 title (e.g. the ColorPicker
|
||||
editor exposes its name only via UIA). **Use `ListByApp`/`WaitForWindowByApp` with a process filter**
|
||||
for those.
|
||||
|
||||
## 7. What `.Next` does NOT (yet) provide
|
||||
|
||||
When a legacy test relies on one of these, adapt rather than expecting a drop-in:
|
||||
|
||||
- **`By.XPath` / `By.CssSelector` / `By.ClassName`** — none exist (see §3).
|
||||
- **`FindByPattern` / regex Name matching** as a base helper — re-express with `FindAll<T>(By.Name(...))`
|
||||
+ a C# `Regex`/`Where` on the cached `Name` (the legacy base's `FindByNamePattern` shows the shape).
|
||||
- **`Group`, `HyperlinkButton` wrappers** — the legacy `Element/` set has them; `.Next` doesn't.
|
||||
Use `Find<Element>` (or `Find<Button>` for a hyperlink button, which is a Button under UIA), or add a
|
||||
tiny wrapper subclass mirroring `Button.cs`/`NavigationViewItem.cs` if you need the type.
|
||||
- **`element.Text` / `element.Rect` / `element.Enabled`** (legacy names) — use `GetValue()` /
|
||||
`X,Y,Width,Height` / `IsEnabled` (see [api-mapping.md](api-mapping.md)).
|
||||
- **Instance `Session.SendKeys`/`MoveMouseTo`/`PerformMouseAction`** — exist as a thin `Session.SendKeys`
|
||||
passthrough, but prefer the static `KeyboardHelper`/`MouseHelper`.
|
||||
|
||||
If a genuinely missing capability blocks a port, add it to the `.Next` harness in a small, focused way
|
||||
that mirrors the existing file style (one wrapper class, or one static helper method) — and call it out
|
||||
to the user. Don't pull in a NuGet package.
|
||||
357
.github/skills/ui-tests-migration/references/patterns-and-pitfalls.md
vendored
Normal file
357
.github/skills/ui-tests-migration/references/patterns-and-pitfalls.md
vendored
Normal file
@@ -0,0 +1,357 @@
|
||||
# Patterns & pitfalls
|
||||
|
||||
Adaptable recipes for the recurring PowerToys UI-test patterns, plus the gotchas that bite during a
|
||||
`.Next` migration. **These are patterns, not a script** — every module differs; lift the shape, not
|
||||
the literal strings. All snippets assume `using Microsoft.PowerToys.UITest.Next;` and a class deriving
|
||||
from `UITestBase`.
|
||||
|
||||
## Recipe 1 — Navigate to a module's Settings page
|
||||
|
||||
Two common shapes. Prefer the NavigationView item by `AutomationId` when the module has one:
|
||||
|
||||
```csharp
|
||||
// Stable: the left-nav item (a ListItem) by AutomationId. Expand the parent group first if needed.
|
||||
if (Session.Has(By.AccessibilityId("ScreenRulerNavItem"), 500) == false)
|
||||
{
|
||||
Session.Find<NavigationViewItem>(By.AccessibilityId("SystemToolsNavItem")).Click(msPostAction: 500);
|
||||
}
|
||||
Session.Find<NavigationViewItem>(By.AccessibilityId("ScreenRulerNavItem")).Click(msPostAction: 500);
|
||||
```
|
||||
|
||||
```csharp
|
||||
// Dashboard utility-stack label that has no InvokePattern (the click is handled by the ancestor
|
||||
// SettingsCard). A Name search may return several elements — disambiguate, then MouseClick (real
|
||||
// mouse), not Click (UIA invoke), because the label itself isn't invokable.
|
||||
var matches = Session.FindAll<Element>(By.Name("Color Picker"));
|
||||
var label = matches.Where(m => m.ClassName.Equals("TextBlock", StringComparison.OrdinalIgnoreCase))
|
||||
.OrderByDescending(m => m.X) // rightmost = the utility-stack label
|
||||
.First();
|
||||
label.MouseClick(msPostAction: 800);
|
||||
```
|
||||
|
||||
> Pitfall: a `By.Name("Color Picker")` substring search can match a quick-access tile, its label, the
|
||||
> utility-stack label, and a `ToggleSwitch`. Use `FindAll` + a C# filter on `ClassName`/`ControlType`/
|
||||
> coordinates instead of assuming a single hit.
|
||||
|
||||
## Recipe 2 — Toggle a module on/off and verify its process
|
||||
|
||||
```csharp
|
||||
// The page-level enable switch. ToggleSwitch pins ClassName="ToggleSwitch", so the Name search
|
||||
// won't grab a sibling Button with the same Name (e.g. a dashboard card).
|
||||
var toggle = Find<ToggleSwitch>(By.Name("Color Picker"));
|
||||
bool initial = toggle.IsOn;
|
||||
|
||||
toggle.Toggle(false); // flips only if currently on
|
||||
Assert.IsTrue(toggle.WaitForProperty("ToggleState", "Off", 5_000), "UI didn't flip to Off.");
|
||||
Assert.IsTrue(WaitForProcess("PowerToys.ColorPickerUI", false, 10_000), "Process didn't exit.");
|
||||
|
||||
toggle.Toggle(true);
|
||||
Assert.IsTrue(toggle.WaitForProperty("ToggleState", "On", 5_000), "UI didn't flip to On.");
|
||||
Assert.IsTrue(WaitForProcess("PowerToys.ColorPickerUI", true, 10_000), "Process didn't start.");
|
||||
// ... restore `initial` in a finally ...
|
||||
```
|
||||
|
||||
```csharp
|
||||
// Poll for process presence — no built-in, so keep a small helper (from the ColorPicker example).
|
||||
private static bool WaitForProcess(string name, bool expected, int timeoutMS)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + TimeSpan.FromMilliseconds(timeoutMS);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if ((Process.GetProcessesByName(name).Length > 0) == expected) return true;
|
||||
Thread.Sleep(250);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
```
|
||||
|
||||
> Process names are the `-a` names (no `.exe`): `PowerToys.ColorPickerUI`, `PowerToys.ScreenRuler`
|
||||
> (actually `PowerToys.MeasureToolUI`), `PowerToys.FancyZonesEditor`, etc. — see `ModuleConfigData.cs`
|
||||
> in the harness for the authoritative list.
|
||||
|
||||
## Recipe 3 — Read the activation shortcut from a `ShortcutControl`
|
||||
|
||||
PowerToys' `ShortcutControl` renders the current chord on its inner `EditButton`, exposing the readable
|
||||
text (e.g. `"Win + Shift + C"`) via `AutomationProperties.HelpText`. `x:Name` reflects as the
|
||||
`AutomationId` in WinUI when none is set, so:
|
||||
|
||||
```csharp
|
||||
var editButton = Find<Button>(By.AccessibilityId("EditButton"));
|
||||
string shortcutText = editButton.HelpText; // "Win + Shift + C"
|
||||
Key[] keys = ParseShortcutText(shortcutText); // -> [LWin, Shift, C]
|
||||
```
|
||||
|
||||
When the page has several shortcut controls, scope the search under the specific card first:
|
||||
|
||||
```csharp
|
||||
var card = Session.Find<Element>(By.AccessibilityId("Shortcut_ScreenRuler"));
|
||||
var editButton = card.Find<Element>(By.AccessibilityId("EditButton"));
|
||||
```
|
||||
|
||||
```csharp
|
||||
// Shortcut-string parser (ports verbatim from either example; note "win" -> Key.LWin).
|
||||
private static Key[] ParseShortcutText(string s)
|
||||
{
|
||||
var parts = s.Split(new[] { " + ", "+", " " }, StringSplitOptions.RemoveEmptyEntries);
|
||||
var keys = new List<Key>();
|
||||
foreach (var raw in parts)
|
||||
{
|
||||
var p = raw.Trim().ToLowerInvariant();
|
||||
Key? k = p switch
|
||||
{
|
||||
"win" or "windows" => Key.LWin,
|
||||
"ctrl" or "control" => Key.Ctrl,
|
||||
"shift" => Key.Shift,
|
||||
"alt" => Key.Alt,
|
||||
_ when p.Length == 1 && p[0] >= 'a' && p[0] <= 'z' => (Key)Enum.Parse(typeof(Key), p.ToUpperInvariant()),
|
||||
_ => null,
|
||||
};
|
||||
if (k.HasValue) keys.Add(k.Value);
|
||||
}
|
||||
return keys.ToArray();
|
||||
}
|
||||
```
|
||||
|
||||
## Recipe 4 — Fire a global hotkey reliably
|
||||
|
||||
The runner arms its low-level keyboard hook **asynchronously** after a module is enabled, so the very
|
||||
first chord can be lost. Re-send with patient polling between attempts — and don't re-send too eagerly,
|
||||
because for some modules re-sending hides/re-shows the target window:
|
||||
|
||||
```csharp
|
||||
const int attempts = 3;
|
||||
Session? overlay = null;
|
||||
for (int i = 1; i <= attempts && overlay is null; i++)
|
||||
{
|
||||
KeyboardHelper.SendKeys(keys);
|
||||
overlay = WindowsFinder.WaitForWindowByApp(
|
||||
"PowerToys.ColorPickerUI", w => w.Width < 300 && w.Height < 200, timeoutMS: 2_500);
|
||||
|
||||
if (overlay is null)
|
||||
{
|
||||
MouseHelper.MoveTo(cx + 60, cy + 60); // recovery nudge for cursor-following overlays
|
||||
overlay = WindowsFinder.WaitForWindowByApp(
|
||||
"PowerToys.ColorPickerUI", w => w.Width < 300 && w.Height < 200, timeoutMS: 2_500);
|
||||
}
|
||||
}
|
||||
Assert.IsNotNull(overlay, "Activation window did not appear after retries.");
|
||||
```
|
||||
|
||||
> Only the runner's centralized hook can catch a global PowerToys hotkey, which is *why* tests launch
|
||||
> through the Settings/runner scope. `KeyboardHelper.SendKeys` holds `LWin` via `keybd_event` while
|
||||
> sending the rest through SendInput — pure injection doesn't reliably trigger `RegisterHotKey`.
|
||||
|
||||
## Recipe 5 — Inspect the clipboard around an action
|
||||
|
||||
```csharp
|
||||
ClipboardHelper.Clear();
|
||||
MouseHelper.LeftClick(); // the action that copies
|
||||
string captured = ClipboardHelper.WaitForText(ignoredValue: string.Empty, timeoutMS: 3_000);
|
||||
Assert.IsFalse(string.IsNullOrEmpty(captured), "Nothing was copied within 3s.");
|
||||
```
|
||||
|
||||
`ClipboardHelper` already marshals to an STA thread and swallows contention errors — delete any legacy
|
||||
hand-rolled STA wrapper.
|
||||
|
||||
## Recipe 6 — Discover overlay vs. editor windows from one process
|
||||
|
||||
```csharp
|
||||
// Small overlay (transparent/topmost) — filter by size.
|
||||
var overlay = WindowsFinder.WaitForWindowByApp(
|
||||
"PowerToys.ColorPickerUI", w => w.Width < 300 && w.Height < 200, timeoutMS: 2_500);
|
||||
|
||||
// Larger editor window from the SAME process.
|
||||
var editor = WindowsFinder.WaitForWindowByApp(
|
||||
"PowerToys.ColorPickerUI", w => w.Width > 300 && w.Height > 300, timeoutMS: 10_000);
|
||||
|
||||
// Each returns a Session bound to that window; search within it:
|
||||
var peer = overlay!.Find(By.AccessibilityId("ColorHexAutomationPeer"), timeoutMS: 2_000);
|
||||
string hex = peer.Name;
|
||||
```
|
||||
|
||||
> Use `ListByApp`/`WaitForWindowByApp` (process-filtered), **not** `ListAll`, for windows that expose
|
||||
> their name only via UIA (no Win32 title) — the unfiltered list drops them.
|
||||
|
||||
## Recipe 7 — Walk a window's UIA tree (when there's no single selector)
|
||||
|
||||
```csharp
|
||||
var tree = editor.Inspect(depth: 12); // JsonElement: { windows:[{ elements:[{type,name,value,children}] }] }
|
||||
var values = new List<(string Type, string Name, string Value)>();
|
||||
WalkElements(tree, values); // recursive walk (see ColorPicker example)
|
||||
bool found = values.Any(v =>
|
||||
v.Name.Contains(captured, StringComparison.OrdinalIgnoreCase) ||
|
||||
v.Value.Contains(captured, StringComparison.OrdinalIgnoreCase));
|
||||
Assert.IsTrue(found, $"'{captured}' not found in editor tree.");
|
||||
```
|
||||
|
||||
Use this when a value can appear in any of several controls (e.g. ColorPicker's editor renders the
|
||||
captured color in whichever format control matches) and you only need "it's somewhere in the tree".
|
||||
|
||||
## Recipe 8 — Read a value the UIA Name hides
|
||||
|
||||
When `AutomationProperties.Name` overrides the UIA Name with a friendly label (e.g. a color *name*
|
||||
instead of its HEX), `GetValue()` still reads the underlying Text/Value binding:
|
||||
|
||||
```csharp
|
||||
string displayed = Find<TextBlock>(By.AccessibilityId("SomeLabel")).GetValue(); // the real text, not the Name
|
||||
```
|
||||
|
||||
## Recipe 9 — Enable ONLY the module under test (deterministic, faster, isolated)
|
||||
|
||||
Pass `enableModules` to the base ctor so exactly those modules are on before launch — and for a
|
||||
single-module suite, pass **just the one you're testing**. `ConfigureGlobalModuleSettings` enables the
|
||||
named modules and **disables every other one**, so the runner boots only what you need:
|
||||
|
||||
```csharp
|
||||
// All five ScreenRuler test classes do this; ColorPicker too. The key is the settings.json
|
||||
// "enabled" name (note spaces, e.g. "Measure Tool", "PowerToys Run") — see the enabled section of
|
||||
// %LocalAppData%\Microsoft\PowerToys\settings.json or ModuleConfigData.
|
||||
public MyTests() : base(PowerToysModule.PowerToysSettings, enableModules: new[] { "Measure Tool" }) { }
|
||||
```
|
||||
|
||||
Why it's worth doing on every per-module suite:
|
||||
|
||||
- **Faster on a fresh profile (CI).** The runner's `start_enabled_powertoys` phase starts each enabled
|
||||
module; on a clean CI profile that's ~15 default-on modules (~10s). Enabling one cuts that to ~1s
|
||||
(~9s saved per cold start). *(The hotkey register/unregister loop runs over all modules regardless,
|
||||
so it's unchanged — the win is the start phase.)* Locally it's timing-neutral.
|
||||
- **Isolated + deterministic.** No other module's global hotkey, overlay, or tray behavior can
|
||||
interfere with your gesture, and the test starts from a known on/off baseline instead of whatever
|
||||
`settings.json` happened to hold.
|
||||
|
||||
It's compatible with tests that toggle the module themselves (e.g. ColorPicker toggles OFF→ON to check
|
||||
the process lifecycle) — the module just starts already-enabled.
|
||||
|
||||
For a per-module *setting* (not just enable/disable), edit the module's own settings file before launch:
|
||||
|
||||
```csharp
|
||||
SettingsConfigHelper.UpdateModuleSettings(
|
||||
"ColorPicker",
|
||||
defaultSettingsContent: "{}",
|
||||
settings => settings["copiedColorRepresentation"] = "HEX");
|
||||
```
|
||||
|
||||
## Recipe 10 — Drive controls that live in a *different* window (process-scoped session)
|
||||
|
||||
A module's toolbar / overlay / editor is a separate window from Settings. The legacy `global: true`
|
||||
Find reached into it implicitly; in `.Next` bind a session to that **process** and search there.
|
||||
`Session.FromProcess` uses the `-a` (process) scope, so it resolves a control across whichever of the
|
||||
process's windows owns it — ideal for a toolbar that may be one of several windows.
|
||||
|
||||
```csharp
|
||||
// Screen Ruler's toolbar buttons live in PowerToys.MeasureToolUI, NOT the Settings window.
|
||||
var ruler = Session.FromProcess("PowerToys.MeasureToolUI", PowerToysModule.ScreenRuler, timeoutMS: 5_000);
|
||||
ruler.Find<Element>(By.AccessibilityId("Button_Spacing"), 15_000).Click();
|
||||
```
|
||||
|
||||
> **Process name ≠ window title.** The Measure Tool's window *title* is `"PowerToys.ScreenRuler"`, but
|
||||
> the *process* name winappcli's `-a` flag needs is `"PowerToys.MeasureToolUI"`. The authoritative
|
||||
> process names are in the harness's `ModuleConfigData.cs`.
|
||||
|
||||
## Recipe 11 — Center the cursor before a coordinate measurement
|
||||
|
||||
```csharp
|
||||
var size = System.Windows.Forms.SystemInformation.PrimaryMonitorSize; // PHYSICAL px when DPI-aware (Pitfall 12)
|
||||
int cx = size.Width / 2, cy = size.Height / 2;
|
||||
MouseHelper.MoveTo(cx, cy); // park at a known on-screen spot
|
||||
MouseHelper.Drag(cx - 50, cy - 50, cx + 49, cy + 49); // 100x100 box centred on screen
|
||||
```
|
||||
|
||||
Never anchor a gesture to the *current* cursor (`GetMousePosition() + 200`) — the cursor can be
|
||||
anywhere (often near the bottom edge after a toolbar pops up), pushing the gesture off-screen and
|
||||
producing a wrong/empty measurement. `System.Windows.Forms` flows transitively from the harness
|
||||
(`UseWindowsForms=true`), so you can call `SystemInformation` without adding a reference.
|
||||
|
||||
**Move in steps so the overlay tracks the cursor.** A coordinate gesture must land on-screen, and the
|
||||
module's overlay needs to see the cursor *move* before the click — a single `SetCursorPos` can land
|
||||
without a tracked move, leaving the measurement empty. Park at a known on-screen point (screen-centre)
|
||||
and move in a couple of steps:
|
||||
|
||||
```csharp
|
||||
var (cx, cy) = ScreenCenter();
|
||||
MouseHelper.MoveTo(cx - 60, cy - 60); // first move...
|
||||
Thread.Sleep(200);
|
||||
MouseHelper.MoveTo(cx, cy); // ...then settle on the target so the overlay is tracking
|
||||
Thread.Sleep(400);
|
||||
MouseHelper.LeftClick(); // or Drag(...) for a free-form box
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pitfalls
|
||||
|
||||
1. **`Click` has no `msPreAction` in `.Next`.** Legacy `Click(msPreAction: 1000, msPostAction: 2000)`
|
||||
→ `Thread.Sleep(1000); el.Click(msPostAction: 2000);`. Forgetting the pre-delay causes flaky clicks
|
||||
on slow-rendering pages.
|
||||
2. **`Click` (invoke) vs. `MouseClick` (real mouse).** `Click` uses UIA InvokePattern (and falls back
|
||||
to Toggle/Select/Expand). For elements with **no** invoke pattern (TextBlocks, list labels, headers
|
||||
whose ancestor handles the click), `Click` silently does nothing useful — use `MouseClick`.
|
||||
3. **`By.Name` is a substring match and may return many hits.** Always `FindAll` + filter when the
|
||||
name isn't unique. Prefer `By.AccessibilityId`.
|
||||
4. **No `global` parameter.** If a legacy `Find(by, t, global: true)` reached into a popup/other
|
||||
window, switch the session scope (`Session.FromProcess`) or discover the window via `WindowsFinder`.
|
||||
5. **`PowerToysModule.FancyZone` was renamed to `FancyZonesEditor`.** Update the enum value.
|
||||
6. **Don't launch overlay/utility module exes standalone.** Drive `ColorPicker`/`LightSwitch`/etc.
|
||||
through the `PowerToysSettings` scope so the runner owns the hotkey and toggles; a standalone exe
|
||||
has no runner behind it.
|
||||
7. **`System.Threading.Timer` is ambiguous** in this harness (WinForms is referenced and also defines
|
||||
`Timer`). Fully-qualify if you add one. (Rare in tests, common if you port harness-level code.)
|
||||
8. **Cached element geometry is a snapshot.** Re-`Find` before using `X/Y/Width/Height` for a
|
||||
drag/mouse-click if the UI moved since the lookup.
|
||||
9. **Restore state you change.** Toggles, settings.json edits, and clipboard contents must be restored
|
||||
in a `finally` so a failure mid-test doesn't poison the next one. Make cleanup tolerant
|
||||
(`WindowControl.Try*`) so it never masks the real failure.
|
||||
10. **First-build/NuGet errors** → run `tools\build\build-essentials.cmd` once before the per-project
|
||||
build (or `dotnet restore <csproj> -p:Platform=x64`). A missing `project.assets.json` shows up as
|
||||
`NETSDK1004`. Missing `Common.Dotnet.CsWinRT.props` import → CI's `verifyCommonProps.ps1` fails;
|
||||
the template already includes it.
|
||||
11. **`winapp.exe` missing at run time** is expected on a headless agent — the project still *builds*.
|
||||
Don't treat a missing-CLI run failure as a migration defect; report build-clean + ready-to-run.
|
||||
12. **Coordinate-exact tests need an `app.manifest` with `PerMonitorV2`.** Without it the test host is
|
||||
DPI-unaware, so `MouseHelper`'s `SetCursorPos`/`GetCursorPos` coordinates are virtualized by the
|
||||
display scale and stop matching winappcli's PHYSICAL-pixel bounds. On a 150% display a 99px drag
|
||||
measured as ~149px (Screen Ruler reported `150 x 149` instead of `100 x 100`). Copy the manifest
|
||||
from the module's legacy UITests project (or [templates/app.manifest](../templates/app.manifest))
|
||||
and add `<ApplicationManifest>app.manifest</ApplicationManifest>` to the csproj. Regex-only
|
||||
assertions (e.g. `\d+ x \d+`) don't notice the scale — only exact-value tests fail, which makes
|
||||
this easy to miss.
|
||||
**Why the legacy project's manifest doesn't save it:** a legacy `OutputType=Library` test runs
|
||||
inside `testhost.exe` (vstest), whose manifest — not the test DLL's — governs DPI awareness, so the
|
||||
legacy `app.manifest` is silently ignored and its coordinate-exact tests can't be DPI-correct on a
|
||||
scaled display (the ScreenRuler legacy Bounds test fails `150 x 149` even *with* its manifest). A
|
||||
`.Next` project is an `OutputType=Exe` (MTP), so ITS manifest applies to its own process — which is
|
||||
why adding the manifest actually fixes the port, and can make it pass where the legacy can't.
|
||||
13. **Anchor coordinate gestures to the screen centre, not the current cursor** (Recipe 11). This is
|
||||
the #1 cause of "measurement is wrong/empty" — the cursor drifts to the bottom edge after a
|
||||
toolbar appears.
|
||||
14. **Global-hotkey activation is racy right after enabling a module.** The runner arms its keyboard
|
||||
hook asynchronously, so the first chord is easily lost. Settle ~1.5s after the toggle, then
|
||||
re-send the chord and poll for the window, for several attempts (SKILL Recipe 4; the ScreenRuler
|
||||
`SendShortcutUntilVisible` helper is the reference).
|
||||
15. **Per-test cold relaunch amplifies flakiness.** By default each `[TestMethod]` kills + relaunches
|
||||
the runner, so every test pays the startup + hook-arming cost. For a suite of cheap cases against
|
||||
one page, consider `ReuseScopeAcrossTests => true` (one launch per class). Content-dependent
|
||||
measurements (spacing edge-detection) also vary with what's under the cursor — assert on **format**
|
||||
(regex) unless the gesture is content-independent (a free-form drag like Bounds), where an exact
|
||||
value is safe.
|
||||
16. **Coordinate gestures break when the window/cursor is off-screen — and it only shows on CI.** A
|
||||
`WindowSize` preset that resized but kept its old top-left could push the Settings window (and the
|
||||
measurement area) partially off a same-sized 1920×1080 CI display, so the gesture landed off-screen
|
||||
and nothing was captured (empty clipboard). It passed **locally** only because a higher-res dev
|
||||
display left everything on-screen — so don't trust a local pass for coordinate tests. The harness
|
||||
now **centers and clamps** `WindowSize` presets to ~90% of the display, keeping the window fully
|
||||
on-screen; anchor gestures to `ScreenCenter()` (always on-screen) and move in steps (Recipe 11).
|
||||
You do **not** need to minimize or move the covering window — an overlay module like the Measure
|
||||
Tool captures the gesture even with the Settings window underneath (verified); the failure was the
|
||||
off-screen position, not the window covering the centre.
|
||||
17. **The first-run "Welcome to PowerToys" / "What's new" window appears on a fresh profile (CI) and
|
||||
eats centre-screen gestures.** On a clean profile the runner opens the OOBE (Welcome) or SCOOBE
|
||||
(what's-new) window — **centered and topmost** — so a coordinate measurement at screen-centre lands
|
||||
on it instead of the module overlay (empty clipboard). It never shows on a dev box because your
|
||||
profile already marked them seen — the *same* local-passes/CI-fails trap as Pitfall 16, and the
|
||||
hardest to spot because the runner log still shows the hotkey firing and the module activating. The
|
||||
harness now suppresses both in `PreTestHygiene` via
|
||||
`SettingsConfigHelper.SuppressFirstRunExperience()` (seeds `oobe_settings.json`
|
||||
`openedAtFirstLaunch=true` + `settings.json` `show_whats_new_after_updates=false`, mirroring the
|
||||
runner's own gating). If you drive coordinate gestures and see "passes local, empty result on CI",
|
||||
suspect a stray fresh-run window first.
|
||||
187
.github/skills/ui-tests-migration/references/porting-workflow.md
vendored
Normal file
187
.github/skills/ui-tests-migration/references/porting-workflow.md
vendored
Normal file
@@ -0,0 +1,187 @@
|
||||
# Porting workflow
|
||||
|
||||
Two end-to-end playbooks. Pick the one matching your scenario (see SKILL.md "Pick your scenario").
|
||||
Both assume you've read [framework-differences.md](framework-differences.md) and have
|
||||
[api-mapping.md](api-mapping.md) open.
|
||||
|
||||
---
|
||||
|
||||
## Scenario A — Port existing legacy tests
|
||||
|
||||
Re-implement an existing `[Module].UITests` project (which references `UITestAutomation.csproj`) as a
|
||||
new `[Module].UITests.Next` project (referencing `UITestAutomation.Next.csproj`), preserving every
|
||||
test's **intent and assertions**.
|
||||
|
||||
### A0. Baseline the legacy suite first — ELEVATED (recommended)
|
||||
|
||||
Before porting, run the **legacy** suite once to learn its real local pass rate. **Run it elevated:**
|
||||
the legacy harness launches PowerToys via `ProcessStartInfo { Verb = "runas" }`, so a non-elevated
|
||||
test host can't complete the launch and **every test fails at startup with a misleading
|
||||
`Win32Exception` cascade** — a false 0/N that looks like "the tests are broken" but is just the run
|
||||
method. (This is exactly why VS Test Explorer passes them: VS runs as admin.) Don't conclude the
|
||||
legacy suite is broken from a non-elevated run.
|
||||
|
||||
```pwsh
|
||||
# 1. Build the legacy project (WinAppDriver-based, OutputType=Library).
|
||||
tools\build\build.cmd -Path src\modules\<Module>\Tests\<Module>.UITests -Platform x64 -Configuration Debug
|
||||
|
||||
# 2. Run ELEVATED. Put the run in a .ps1 and launch it with -Verb RunAs (one UAC prompt) so the
|
||||
# harness's runas launch has an elevated host. The script should start WinAppDriver + run vstest:
|
||||
# $dll = "$PWD\x64\Debug\tests\<Module>.UITests\net10.0-windows10.0.26100.0\<Module>.UITests.dll"
|
||||
# Start-Process "C:\Program Files (x86)\Windows Application Driver\WinAppDriver.exe" -ArgumentList "127.0.0.1","4723"
|
||||
# vstest.console.exe $dll /Platform:x64 /InIsolation /Logger:"trx;LogFileName=legacy.trx" /ResultsDirectory:<dir>
|
||||
# Have the script write a DONE marker at the end; poll for it, then read the .trx.
|
||||
Start-Process pwsh -Verb RunAs -ArgumentList "-NoProfile","-ExecutionPolicy","Bypass","-File","<runner>.ps1"
|
||||
```
|
||||
|
||||
Knowing the baseline tells you which failures are pre-existing product/environment issues you should
|
||||
NOT expect the port to fix. A measurement failure on a scaled (non-100%) display is usually DPI (see
|
||||
[patterns-and-pitfalls.md](patterns-and-pitfalls.md) Pitfall 12): the ScreenRuler legacy suite scores
|
||||
**4/5** elevated here (Bounds fails at 150% scale), while the `.Next` port scores **5/5** — its Exe
|
||||
`app.manifest` makes it DPI-aware where the legacy `Library` project's manifest is silently ignored.
|
||||
|
||||
### A1. Inventory the source
|
||||
|
||||
- List every `[TestClass]` and `[TestMethod]` in the legacy project. Note `[TestCategory]` tags,
|
||||
`DataRow`s, and the base-ctor args (`scope`, `WindowSize`, `commandLineArgs`).
|
||||
- List shared helpers (a `TestHelper`/`*Helpers` static class is common — ScreenRuler's
|
||||
`TestHelper.cs` is the canonical example). Decide per-helper whether to **port it**, **inline it**,
|
||||
or **drop it** (Selenium-only scaffolding usually drops).
|
||||
- For each test, write a one-line statement of *what it asserts* (the behavior), independent of how the
|
||||
legacy harness did it. You're re-creating that behavior, not the Selenium calls.
|
||||
|
||||
### A2. Map the structure
|
||||
|
||||
| Legacy piece | `.Next` target |
|
||||
|---|---|
|
||||
| `[TestClass] FooTests : UITestBase` | same shape, `using Microsoft.PowerToys.UITest.Next;` |
|
||||
| ctor `: base(PowerToysSettings, WindowSize.Large)` | `: base(PowerToysModule.PowerToysSettings, WindowSize.Large)` |
|
||||
| ctor `commandLineArgs: new[]{ "--enable", "Foo" }` | `enableModules: new[]{ "Foo" }` (deterministic module baseline) |
|
||||
| `TestHelper.InitializeTest(this, …)` | a private setup method, or rely on `UITestBase` hygiene + an explicit nav helper |
|
||||
| `[TestMethod("Foo.Bar")]` | `[TestMethod]` + keep `[TestCategory("Foo")]` |
|
||||
|
||||
### A3. Re-implement each test, method by method
|
||||
|
||||
For each legacy method:
|
||||
|
||||
1. **Translate the selectors** first (the highest-risk part). Replace `By.XPath`/`By.ClassName` per
|
||||
[framework-differences.md §3](framework-differences.md). Prefer `By.AccessibilityId` — open the
|
||||
module's XAML and find the `x:Name`/`AutomationProperties.AutomationId` the control exposes.
|
||||
2. **Translate the actions** with [api-mapping.md](api-mapping.md). The frequent ones:
|
||||
- `element.Click(msPreAction: N, …)` → if you relied on the pre-delay, add `Thread.Sleep(N)` then
|
||||
`element.Click(msPostAction: …)` (`.Next` `Click` has no `msPreAction`).
|
||||
- A click on a non-invokable element (TextBlock/ListItem whose ancestor handles it) →
|
||||
`element.MouseClick(...)`.
|
||||
- Selenium `Actions` drags → `element.Drag(...)` / `MouseHelper.Drag(...)`.
|
||||
- `testBase.SendKeys(...)` / `Session.PerformMouseAction(...)` → `KeyboardHelper.*` / `MouseHelper.*`.
|
||||
3. **Translate the waits.** Replace hand-rolled `while (DateTime.Now < end) { … Task.Delay(...) }`
|
||||
poll loops with the built-ins: `element.WaitForProperty("ToggleState","On",t)`,
|
||||
`element.WaitForValue(...)`, `Session.WaitForElement(by,t)`, `Session.WaitFor(() => …, t)`, or
|
||||
`ClipboardHelper.WaitForText(...)`. Keep a custom poll only when you're polling something with no
|
||||
built-in (e.g. `Process.GetProcessesByName(...)` — see the ColorPicker `WaitForProcess` helper).
|
||||
4. **Translate cleanup.** Delete manual `CloseOtherApplications`/`Win+M` (the base does hygiene). For
|
||||
windows the *test* spawned (overlay/editor), close them in a `finally` with
|
||||
`WindowControl.TryCloseByApp("PowerToys.<Module>UI")`. Restore any toggle you flipped to its
|
||||
initial state in a `finally` (see the ColorPicker example's nested `finally`).
|
||||
5. **Keep the assertions identical in spirit** — same things checked, same pass/fail meaning.
|
||||
|
||||
### A4. Port shared helpers thoughtfully
|
||||
|
||||
- Start from [../templates/TestHelper.cs](../templates/TestHelper.cs) — it already implements the
|
||||
common building blocks (navigate, toggle + verify process, read shortcut, discover/activate/close
|
||||
the module window, clipboard, screen-center) with the right `.Next` idioms; map your legacy helper's
|
||||
module-specific bits onto it rather than translating Selenium scaffolding line-by-line.
|
||||
- A static `TestHelper` is fine to keep, but re-point it at the new APIs. Drop members that only
|
||||
existed to work around Selenium (manual `Session.Attach` dances, STA-clipboard wrappers → use
|
||||
`ClipboardHelper`).
|
||||
- Shortcut-string parsing helpers (`ParseShortcutText` turning `"Win + Shift + C"` into `Key[]`) port
|
||||
almost verbatim — just map `"win"` → `Key.LWin`. Both examples include this parser; reuse it.
|
||||
|
||||
### A5. Validate (write → build → run → iterate)
|
||||
|
||||
Build to exit 0 (see [project-setup.md §5](project-setup.md)). Then map each new `[TestMethod]` back
|
||||
to the legacy method it replaces and confirm none were dropped. On a live desktop, **run in a loop**:
|
||||
start with one deterministic test (the activation/toggle test), get it green, then widen to the whole
|
||||
suite. UI runs expose environment-real failures that only show up live — DPI scaling, cursor drift,
|
||||
and hotkey-arming races (all hit during the ScreenRuler port; see
|
||||
[patterns-and-pitfalls.md](patterns-and-pitfalls.md) Pitfalls 12–15). Diagnose each from the TRX
|
||||
failure message + the auto-captured failure screenshots, fix, and re-run — don't just re-run hoping
|
||||
for a different result.
|
||||
|
||||
---
|
||||
|
||||
## Scenario B — Greenfield from a human sign-off markdown
|
||||
|
||||
The module has **no** automated UI tests. Build a new `[Module].UITests` project (no `.Next` suffix)
|
||||
whose tests come from the module's **manual test sign-off** document — the human checklist QA runs
|
||||
before a release. `ColorPickerUITest.md` is the archetype:
|
||||
|
||||
```text
|
||||
* Enable the Color Picker in settings and ensure that the hotkey brings up Color Picker
|
||||
- [] Change `Activate Color Picker shortcut` and check the new shortcut is working
|
||||
- [] Try all three `Activation behavior`s
|
||||
- [] Change `Color format for clipboard` and check if the correct format is copied
|
||||
...
|
||||
```
|
||||
|
||||
### B1. Find the sign-off doc
|
||||
|
||||
- Look in the module's folder and its `Tests/`/`UITests/` subfolders for a `*.md` describing manual
|
||||
test steps (often `<Module>UITest.md`, `<Module>Test.md`, or a section in the module README).
|
||||
Search the repo for the module name + "test"/"checklist" if it's not obvious.
|
||||
- If there's genuinely no doc, **ask the user** for the test spec rather than inventing coverage.
|
||||
|
||||
### B2. Turn each checklist item into a test intent
|
||||
|
||||
For every bullet, write down: **trigger → observable signal → assertion**. Classify each item by how
|
||||
the new harness can drive it (see [patterns-and-pitfalls.md](patterns-and-pitfalls.md) for the
|
||||
recipes):
|
||||
|
||||
| Checklist phrasing | Drive technique | Observable signal |
|
||||
|---|---|---|
|
||||
| "Enable X in settings; module runs" | toggle the page switch | `WaitForProcess("PowerToys.<M>UI", true)` |
|
||||
| "Hotkey brings up X" | read shortcut from `ShortcutControl`, `KeyboardHelper.SendKeys(...)` | the module's window/overlay appears (`WindowsFinder.WaitForWindowByApp`) |
|
||||
| "Change shortcut and it works" | set the new shortcut (UI or settings.json), fire it | window appears for the new chord |
|
||||
| "Change format/option and output matches" | flip the setting, perform the action | clipboard/value matches (`ClipboardHelper.WaitForText`) |
|
||||
| "Value is shown in the UI" | read it | `element.GetValue()` / `.Name` / `.HelpText` equals expected |
|
||||
| "Select/remove item from a list" | `Find`+`Click` the item | list count / selection changes |
|
||||
| "Check logs for errors" | *(usually not automatable)* | note as out-of-scope; don't fake an assertion |
|
||||
|
||||
### B3. Group items into test methods
|
||||
|
||||
- One `[TestMethod]` per coherent scenario, not necessarily one per bullet — several related bullets
|
||||
(enable → read shortcut → activate → capture → verify) often belong in one end-to-end flow, exactly
|
||||
like `ColorPickerEndToEndTests.NavigateReadShortcutActivateAndCapture`.
|
||||
- Add `[TestCategory("<Module>")]` so the suite is filterable.
|
||||
- Drive **through the Settings scope** (`base(PowerToysModule.PowerToysSettings)`) for overlay/utility
|
||||
modules so the runner owns the hotkey and toggles — don't launch the module exe standalone.
|
||||
|
||||
### B4. Make the UI observable (flag, don't fix)
|
||||
|
||||
Sign-off docs assume a human's eyes. Some signals aren't UIA-readable (a transparent overlay's
|
||||
displayed HEX, a canvas color). If an assertion needs a hook the product doesn't expose:
|
||||
|
||||
- First try the existing readouts: `GetValue()` (reads the Text binding even when
|
||||
`AutomationProperties.Name` overrides the UIA Name), `Inspect(...)` tree walks, clipboard, window
|
||||
geometry.
|
||||
- If there's truly no signal, **flag it to the user** that a small test-only UIA hook is needed (like
|
||||
ColorPicker's hidden `ColorHexAutomationPeer` TextBlock — `Visibility=Visible, Opacity=0`, bound to
|
||||
the same source). Do **not** add such a hook to product code yourself without sign-off; describe it
|
||||
and let the user decide.
|
||||
|
||||
### B5. Validate
|
||||
|
||||
Build to exit 0. List each checklist item and the `[TestMethod]` (or `TestContext.WriteLine` note)
|
||||
that covers it, and explicitly call out any items left as manual-only (e.g. "check logs for errors").
|
||||
|
||||
---
|
||||
|
||||
## Both scenarios — definition of done
|
||||
|
||||
- [ ] New project builds to **exit code 0**, referencing `UITestAutomation.Next.csproj` only.
|
||||
- [ ] No Selenium/Appium/`WindowsDriver`/`By.XPath`/`:4723` left anywhere.
|
||||
- [ ] Registered in `PowerToys.slnx` with the `*|ARM64`/`*|x64` platform block.
|
||||
- [ ] (A) Every legacy `[TestMethod]` has a `.Next` counterpart; the legacy project is untouched.
|
||||
- [ ] (B) Every actionable sign-off item maps to a test or is explicitly noted as manual-only.
|
||||
- [ ] Toggles/settings the test changes are restored in a `finally`; spawned windows are closed.
|
||||
- [ ] No product-code edits (or any needed UIA hook is flagged to the user, not silently added).
|
||||
173
.github/skills/ui-tests-migration/references/project-setup.md
vendored
Normal file
173
.github/skills/ui-tests-migration/references/project-setup.md
vendored
Normal file
@@ -0,0 +1,173 @@
|
||||
# Project setup & scaffolding
|
||||
|
||||
How to create, place, name, register, and build the new `.Next` test project. The starter files live
|
||||
in [../templates/](../templates/).
|
||||
|
||||
## 1. Decide the name and location
|
||||
|
||||
| Scenario | Project name | Folder |
|
||||
|---|---|---|
|
||||
| **A — Port** (legacy UI tests exist) | `[Module].UITests.Next` | `src/modules/[Module]/Tests/[Module].UITests.Next/` |
|
||||
| **B — Greenfield** (no UI tests) | `[Module].UITests` | `src/modules/[Module]/Tests/[Module].UITests/` |
|
||||
|
||||
Rules and judgment:
|
||||
|
||||
- **The `.Next` suffix exists only to avoid colliding with an existing legacy project.** If there is
|
||||
nothing to live alongside (Scenario B), drop it.
|
||||
- **Match the module's existing test layout.** Many modules already nest tests under a `Tests/`
|
||||
folder (`MeasureTool/Tests/ScreenRuler.UITests`, `LightSwitch/Tests/LightSwitch.UITests`); others
|
||||
put the UI-tests project directly under the module root (`colorPicker/ColorPicker.UITests`,
|
||||
`fancyzones/FancyZones.UITests`). **Mirror whatever the module already does** — don't invent a new
|
||||
structure. The path-segment count only changes the relative `..\` depth to `common\` in the csproj.
|
||||
- Keep the **`AssemblyName`** matching the project name (`[Module].UITests.Next`) so logs and build
|
||||
artifacts are unambiguous; there's no need to strip the `.Next` from the assembly name.
|
||||
- If the legacy project has an unusual file name (e.g. `HostsEditor.UITests.csproj` inside a
|
||||
`Hosts.UITests/` folder), prefer a clean `[Module].UITests.Next.csproj`; consistency with the new
|
||||
examples (`ColorPicker.UITests.csproj`, `Settings.UITests.csproj`) wins.
|
||||
|
||||
## 2. Scaffold the csproj
|
||||
|
||||
Copy [../templates/Module.UITests.Next.csproj](../templates/Module.UITests.Next.csproj) and replace the
|
||||
`__MODULE__` placeholder (and fix the `..\` depth on the ProjectReference). The reference csproj
|
||||
(ColorPicker, whose project folder sits 3 levels under `src/`) is:
|
||||
|
||||
```xml
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<!-- Look at Directory.Build.props in root for common stuff as well -->
|
||||
<Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" />
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0-windows10.0.26100.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<RootNamespace>Microsoft.__MODULE__.UITests</RootNamespace>
|
||||
<AssemblyName>__MODULE__.UITests.Next</AssemblyName>
|
||||
|
||||
<!-- Microsoft.Testing.Platform: appears in Test Explorer AND runs via dotnet test / vstest. -->
|
||||
<IsTestingPlatformApplication>true</IsTestingPlatformApplication>
|
||||
<EnableMSTestRunner>true</EnableMSTestRunner>
|
||||
<GenerateDocumentationFile>false</GenerateDocumentationFile>
|
||||
|
||||
<!-- UI tests need a live desktop; never run them as part of MSBuild. -->
|
||||
<RunVSTest>false</RunVSTest>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Stage the built test app under <Platform>\<Configuration>\tests\ so the UI-tests build
|
||||
pipeline (CopyFiles glob **/<plat>/<config>/tests/**) picks it up. -->
|
||||
<PropertyGroup>
|
||||
<OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\tests\__MODULE__.UITests.Next\</OutputPath>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MSTest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Adjust the ..\ depth to reach src\common from THIS project's folder. -->
|
||||
<ProjectReference Include="..\..\..\common\UITestAutomation.Next\UITestAutomation.Next.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
```
|
||||
|
||||
Critical, non-negotiable bits (CI audits or the build will fail without them):
|
||||
|
||||
1. **`<Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" />`** immediately after the
|
||||
`<Project Sdk=...>` line. `.pipelines/verifyCommonProps.ps1` requires it on every `src/**` csproj.
|
||||
2. **`OutputType=Exe`**, **`IsTestingPlatformApplication=true`**, **`EnableMSTestRunner=true`** — the
|
||||
Microsoft.Testing.Platform runner the rest of the repo uses; this is what makes the class appear in
|
||||
Test Explorer and run via `dotnet test`/`vstest.console.exe`.
|
||||
3. **`<OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\tests\<Name>\</OutputPath>`** — stages the
|
||||
build output where the UI-tests pipeline globs (`**/<plat>/<config>/tests/**`). Without it the app
|
||||
builds to `bin\` and is never picked up by the test job.
|
||||
4. **`RunVSTest=false`** — UI tests must not run during MSBuild.
|
||||
5. **ProjectReference to `UITestAutomation.Next.csproj` only** — never the legacy
|
||||
`UITestAutomation.csproj`. Fix the `..\` depth to match the folder nesting:
|
||||
- `src/modules/<M>/Tests/<M>.UITests.Next/` (4 levels under `src`) → `..\..\..\..\common\UITestAutomation.Next\UITestAutomation.Next.csproj`
|
||||
- `src/modules/<M>/<M>.UITests/` (3 levels under `src`) → `..\..\..\common\UITestAutomation.Next\UITestAutomation.Next.csproj`
|
||||
- `src/settings-ui/<M>.UITests/` (2 levels under `src`) → `..\..\common\UITestAutomation.Next\UITestAutomation.Next.csproj`
|
||||
|
||||
> Use `MSTest` (the meta-package) for a test **Exe**, matching the ColorPicker/Settings examples — not
|
||||
> the bare `MSTest.TestFramework` the harness library itself uses.
|
||||
|
||||
## 3. Register in `PowerToys.slnx`
|
||||
|
||||
Add the project to [../../../../PowerToys.slnx](../../../../PowerToys.slnx) inside the module's
|
||||
`<Folder>`, right next to the legacy project (Scenario A) so they're visually paired:
|
||||
|
||||
```xml
|
||||
<Project Path="src/modules/<Module>/Tests/<Module>.UITests.Next/<Module>.UITests.Next.csproj">
|
||||
<Platform Solution="*|ARM64" Project="ARM64" />
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
</Project>
|
||||
```
|
||||
|
||||
Match the `<Platform>` mapping block of the sibling projects in the same folder (every UI-tests entry
|
||||
uses the `*|ARM64 → ARM64` / `*|x64 → x64` pair shown above).
|
||||
|
||||
## 4. Add the test class(es) and shared helper
|
||||
|
||||
Copy [../templates/ModuleEndToEndTests.cs](../templates/ModuleEndToEndTests.cs) into the project,
|
||||
rename it to `[Module]EndToEndTests.cs` (or keep the legacy test-class names in Scenario A), and start
|
||||
filling in test methods.
|
||||
|
||||
For anything beyond a single trivial test, also copy
|
||||
[../templates/TestHelper.cs](../templates/TestHelper.cs) — a static helper with the reusable building
|
||||
blocks every port needs (navigate to the page, toggle + verify the process, read the activation
|
||||
shortcut, discover/activate/close the module window with patient retry, clipboard, screen-center).
|
||||
Fill in the `__MODULE__` / `__MODULEUI__` / AutomationId placeholders and delete what you don't use.
|
||||
This mirrors how the legacy suites are organized (a `TestHelper` + thin test classes) and is exactly
|
||||
the shape of the validated ScreenRuler port.
|
||||
|
||||
The standard file header is required on every `.cs`:
|
||||
|
||||
```csharp
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
```
|
||||
|
||||
## 4b. (Coordinate-exact tests only) add a DPI-aware `app.manifest`
|
||||
|
||||
If any test drives the mouse by **pixel coordinates** and asserts on an **exact** value (a drag that
|
||||
must measure `100 x 100`, a click at a precise point), the test host MUST be per-monitor DPI aware,
|
||||
otherwise `MouseHelper`'s `SetCursorPos`/`GetCursorPos` are virtualized by the display scale and stop
|
||||
matching winappcli's physical-pixel bounds (a 99px drag measured ~149px on a 150% display).
|
||||
|
||||
Copy [../templates/app.manifest](../templates/app.manifest) into the project (or the one from the
|
||||
module's legacy UITests project) and reference it in the csproj:
|
||||
|
||||
```xml
|
||||
<PropertyGroup>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
</PropertyGroup>
|
||||
```
|
||||
|
||||
Tests that only assert on **format** (regex like `\d+ x \d+`) or never touch raw coordinates don't
|
||||
need the manifest — which is why ColorPicker/Settings `.Next` projects omit it.
|
||||
|
||||
## 5. Build & run
|
||||
|
||||
```pwsh
|
||||
# 0. FIRST build of a new project: restore so project.assets.json exists (else NETSDK1004).
|
||||
dotnet restore src\modules\<Module>\Tests\<Module>.UITests.Next\<Module>.UITests.Next.csproj -p:Platform=x64
|
||||
# (or run tools\build\build-essentials.cmd once at the start of the session.)
|
||||
|
||||
# 1. Build only this project (fast). Exit code 0 = success.
|
||||
tools\build\build.cmd -Path src\modules\<Module>\Tests\<Module>.UITests.Next -Platform x64 -Configuration Debug
|
||||
|
||||
# 2. Run (needs a live desktop + winapp.exe). A .Next project is a Microsoft.Testing.Platform Exe,
|
||||
# so run the produced exe directly (Test Explorer also works). Filter + TRX report for a tight loop:
|
||||
$exe = "$PWD\x64\Debug\tests\<Module>.UITests.Next\net10.0-windows10.0.26100.0\<Module>.UITests.Next.exe"
|
||||
& $exe --filter "TestCategory=<Cat>" --report-trx --report-trx-filename run.trx --results-directory .\TestResults\<Module>
|
||||
# --filter accepts "TestCategory=X" or "FullyQualifiedName~Y"; omit it to run everything. Exit 0 = all passed.
|
||||
```
|
||||
|
||||
- On build failure, read `build.<Configuration>.<Platform>.errors.log` next to the project.
|
||||
- `winapp.exe` is a **run-time** prerequisite only (`winget install Microsoft.winappcli`, or set
|
||||
`WINAPP_CLI_PATH`). A migration that compiles clean is valid even where the CLI/desktop is absent;
|
||||
say so and list coverage.
|
||||
- `dotnet test` also works for a one-shot run, but prefer the produced exe for a fast iterate loop and
|
||||
do **not** run UI tests from inside an MSBuild step — they need an interactive session.
|
||||
54
.github/skills/ui-tests-migration/templates/Module.UITests.Next.csproj
vendored
Normal file
54
.github/skills/ui-tests-migration/templates/Module.UITests.Next.csproj
vendored
Normal file
@@ -0,0 +1,54 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<!--
|
||||
TEMPLATE — copy into src/modules/<Module>/Tests/<Module>.UITests.Next/ and replace:
|
||||
__MODULE__ -> the module name used for the project/assembly (e.g. ColorPicker, ScreenRuler)
|
||||
the ProjectReference ..\ depth -> enough ..\ to reach src\common from THIS folder
|
||||
(see references/project-setup.md §2)
|
||||
For a GREENFIELD project (module had no UI tests), rename the file and AssemblyName to drop
|
||||
the ".Next" suffix (use <Module>.UITests).
|
||||
-->
|
||||
|
||||
<!-- REQUIRED: must be the first line after <Project>. CI (verifyCommonProps.ps1) audits this. -->
|
||||
<Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" />
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0-windows10.0.26100.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<RootNamespace>Microsoft.__MODULE__.UITests</RootNamespace>
|
||||
<AssemblyName>__MODULE__.UITests.Next</AssemblyName>
|
||||
|
||||
<!--
|
||||
Microsoft.Testing.Platform: the modern runner Directory.Build.props enables repo-wide, so this
|
||||
test class appears in Test Explorer AND can be run via `dotnet test` / `vstest.console.exe`.
|
||||
-->
|
||||
<IsTestingPlatformApplication>true</IsTestingPlatformApplication>
|
||||
<EnableMSTestRunner>true</EnableMSTestRunner>
|
||||
<GenerateDocumentationFile>false</GenerateDocumentationFile>
|
||||
|
||||
<!-- UI tests need a live desktop; never run them as part of MSBuild. -->
|
||||
<RunVSTest>false</RunVSTest>
|
||||
</PropertyGroup>
|
||||
|
||||
<!--
|
||||
Stage the built test app under <Platform>\<Configuration>\tests\ so the UI-tests build pipeline
|
||||
(CopyFiles glob **/<plat>/<config>/tests/**) picks it up, matching the other *.UITests projects.
|
||||
Without this it builds to bin\ and is never staged into the artifact.
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\tests\__MODULE__.UITests.Next\</OutputPath>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MSTest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Reference the NEW harness only. NEVER reference ..\common\UITestAutomation\UITestAutomation.csproj.
|
||||
Adjust the ..\ depth so it resolves from this project's folder to src\common. -->
|
||||
<ProjectReference Include="..\..\..\..\common\UITestAutomation.Next\UITestAutomation.Next.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
144
.github/skills/ui-tests-migration/templates/ModuleEndToEndTests.cs
vendored
Normal file
144
.github/skills/ui-tests-migration/templates/ModuleEndToEndTests.cs
vendored
Normal file
@@ -0,0 +1,144 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
// TEMPLATE — a starting scaffold for a `.Next` UI-test class. Replace __MODULE__ / __MODULEUI__ /
|
||||
// selectors with the real values for your module, delete what you don't need, and add test methods.
|
||||
// See the skill's references/patterns-and-pitfalls.md for the full recipe catalog and
|
||||
// ColorPickerEndToEndTests.cs for a complete worked example.
|
||||
using System.Diagnostics;
|
||||
using Microsoft.PowerToys.UITest.Next;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace Microsoft.__MODULE__.UITests;
|
||||
|
||||
[TestClass]
|
||||
public class __MODULE__EndToEndTests : UITestBase
|
||||
{
|
||||
// Drive overlay/utility modules through the Settings scope so the runner owns the activation
|
||||
// hotkey and module toggles. `enableModules` enables ONLY the listed modules (disabling the rest)
|
||||
// before launch — pass just the one under test so the runner boots a single module (faster on a
|
||||
// fresh CI profile + isolated from other modules' hotkeys/overlays). The name is the settings.json
|
||||
// "enabled" key (note spaces, e.g. "Measure Tool", "PowerToys Run"). Add a WindowSize if needed.
|
||||
public __MODULE__EndToEndTests()
|
||||
: base(PowerToysModule.PowerToysSettings, enableModules: new[] { "__MODULE_SETTINGS_KEY__" })
|
||||
{
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("__MODULE__")]
|
||||
public void ExampleScenario()
|
||||
{
|
||||
try
|
||||
{
|
||||
RunTest();
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Tolerant cleanup — close any window the test spawned, then Settings. Never throws, so it
|
||||
// can't mask the real failure.
|
||||
WindowControl.TryCloseByApp("__MODULEUI__");
|
||||
WindowControl.TryCloseByApp("PowerToys.Settings");
|
||||
}
|
||||
}
|
||||
|
||||
private void RunTest()
|
||||
{
|
||||
// 1. Navigate to the module's Settings page (adjust selector / nav-item id for your module).
|
||||
// Some pages use a left-nav NavigationViewItem by AutomationId; others a dashboard label.
|
||||
// Session.Find<NavigationViewItem>(By.AccessibilityId("__MODULE__NavItem")).Click(msPostAction: 500);
|
||||
|
||||
// 2. Find the page enable toggle and verify the module process follows it.
|
||||
var toggle = Find<ToggleSwitch>(By.Name("__MODULE__"));
|
||||
bool initialIsOn = toggle.IsOn;
|
||||
|
||||
try
|
||||
{
|
||||
if (!toggle.IsOn)
|
||||
{
|
||||
toggle.Toggle(true);
|
||||
Assert.IsTrue(toggle.WaitForProperty("ToggleState", "On", 5_000), "Toggle didn't turn On.");
|
||||
Assert.IsTrue(WaitForProcess("__MODULEUI__", expected: true, 10_000), "Process didn't start.");
|
||||
}
|
||||
|
||||
// 3. Read the activation shortcut from the ShortcutControl's EditButton (HelpText carries
|
||||
// the readable chord, e.g. "Win + Shift + C").
|
||||
var editButton = Find<Button>(By.AccessibilityId("EditButton"));
|
||||
Key[] keys = ParseShortcutText(editButton.HelpText);
|
||||
Assert.IsTrue(keys.Length > 0, $"Could not parse shortcut '{editButton.HelpText}'.");
|
||||
|
||||
// 4. Fire the hotkey (retry — the runner arms its hook asynchronously) and wait for the
|
||||
// module window/overlay to appear.
|
||||
Session? appWindow = null;
|
||||
for (int attempt = 1; attempt <= 3 && appWindow is null; attempt++)
|
||||
{
|
||||
KeyboardHelper.SendKeys(keys);
|
||||
appWindow = WindowsFinder.WaitForWindowByApp("__MODULEUI__", _ => true, timeoutMS: 2_500);
|
||||
}
|
||||
|
||||
Assert.IsNotNull(appWindow, "Module window did not appear after firing the shortcut.");
|
||||
|
||||
// 5. ... assert on the module's UI (read values, click, inspect tree, check clipboard) ...
|
||||
TestContext.WriteLine($"Module window appeared: hwnd={appWindow!.WindowHandle}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Restore the toggle to its initial state, tolerantly.
|
||||
try
|
||||
{
|
||||
if (toggle.IsOn != initialIsOn)
|
||||
{
|
||||
toggle.Toggle(initialIsOn);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Poll for a process becoming present/absent (no built-in wait for this).</summary>
|
||||
private static bool WaitForProcess(string name, bool expected, int timeoutMS)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + TimeSpan.FromMilliseconds(timeoutMS);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if ((Process.GetProcessesByName(name).Length > 0) == expected)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
Thread.Sleep(250);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Parse a UI shortcut string like "Win + Shift + C" into the Key chord.</summary>
|
||||
private static Key[] ParseShortcutText(string shortcutText)
|
||||
{
|
||||
var parts = shortcutText.Split(new[] { " + ", "+", " " }, StringSplitOptions.RemoveEmptyEntries);
|
||||
var keys = new List<Key>();
|
||||
foreach (var raw in parts)
|
||||
{
|
||||
var part = raw.Trim().ToLowerInvariant();
|
||||
Key? key = part switch
|
||||
{
|
||||
"win" or "windows" => Key.LWin,
|
||||
"ctrl" or "control" => Key.Ctrl,
|
||||
"shift" => Key.Shift,
|
||||
"alt" => Key.Alt,
|
||||
_ when part.Length == 1 && part[0] >= 'a' && part[0] <= 'z' =>
|
||||
(Key)Enum.Parse(typeof(Key), part.ToUpperInvariant()),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
if (key.HasValue)
|
||||
{
|
||||
keys.Add(key.Value);
|
||||
}
|
||||
}
|
||||
|
||||
return keys.ToArray();
|
||||
}
|
||||
}
|
||||
218
.github/skills/ui-tests-migration/templates/TestHelper.cs
vendored
Normal file
218
.github/skills/ui-tests-migration/templates/TestHelper.cs
vendored
Normal file
@@ -0,0 +1,218 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
// TEMPLATE — a static helper for a `.Next` UI-test project, distilled from the validated ScreenRuler
|
||||
// port. Copy alongside ModuleEndToEndTests.cs, then:
|
||||
// • Replace __MODULE__ (project name) and __MODULEUI__ (the module's PROCESS name, e.g.
|
||||
// "PowerToys.MeasureToolUI" — NOT the window title; see ModuleConfigData.cs in the harness).
|
||||
// • Fill in the AutomationIds for your module's nav item(s), toggle, and shortcut card from the
|
||||
// module's XAML (or discover them live: `winapp ui search "<id>" -a PowerToys.Settings --json`).
|
||||
// • Delete the helpers you don't need. Keep each helper ADAPTABLE — every module is different.
|
||||
// See references/patterns-and-pitfalls.md for the full recipe catalog these are based on.
|
||||
using Microsoft.PowerToys.UITest.Next;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace Microsoft.__MODULE__.UITests;
|
||||
|
||||
public static class TestHelper
|
||||
{
|
||||
// ── Customize: AutomationIds + process name ───────────────────────────────────────────────
|
||||
// The module's PROCESS name (winappcli -a). Window TITLE may differ — use the process name.
|
||||
public const string ModuleProcess = "__MODULEUI__";
|
||||
|
||||
// Left-nav item AutomationId for the module's Settings page, and its parent group (if the item
|
||||
// lives under a collapsible group like "System Tools"). Set ParentNavItemId to null if there's none.
|
||||
public const string NavItemId = "__MODULE__NavItem";
|
||||
public const string? ParentNavItemId = "SystemToolsNavItem";
|
||||
|
||||
// The page enable ToggleSwitch and the ShortcutControl card AutomationIds.
|
||||
public const string ToggleId = "Toggle___MODULE__";
|
||||
public const string ShortcutCardId = "Shortcut___MODULE__";
|
||||
|
||||
// ── Navigation ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Navigate to the module's Settings page (expanding its parent nav group if needed).</summary>
|
||||
public static void NavigateToPage(UITestBase testBase)
|
||||
{
|
||||
// A collapsible parent group hides its children until expanded; expand only when the child
|
||||
// isn't already in the tree (re-clicking an expanded group would collapse it).
|
||||
if (ParentNavItemId is not null && !testBase.Session.Has(By.AccessibilityId(NavItemId), 500))
|
||||
{
|
||||
testBase.Session.Find<NavigationViewItem>(By.AccessibilityId(ParentNavItemId), 5000).Click(msPostAction: 500);
|
||||
}
|
||||
|
||||
testBase.Session.Find<NavigationViewItem>(By.AccessibilityId(NavItemId), 5000).Click(msPostAction: 800);
|
||||
}
|
||||
|
||||
// ── Toggle ────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Set the page enable toggle and wait for the UI to reflect the new state.</summary>
|
||||
public static ToggleSwitch SetToggle(UITestBase testBase, bool enable)
|
||||
{
|
||||
var toggle = testBase.Session.Find<ToggleSwitch>(By.AccessibilityId(ToggleId), 5000);
|
||||
toggle.Toggle(enable);
|
||||
toggle.WaitForProperty("ToggleState", enable ? "On" : "Off", 5000);
|
||||
return toggle;
|
||||
}
|
||||
|
||||
/// <summary>Set the toggle and assert it (and optionally the module process) reached the state.</summary>
|
||||
public static void SetAndVerifyToggle(UITestBase testBase, bool enable, bool verifyProcess = false, int timeoutMs = 10_000)
|
||||
{
|
||||
var toggle = SetToggle(testBase, enable);
|
||||
Assert.AreEqual(enable, toggle.IsOn, $"Toggle should be {(enable ? "On" : "Off")}.");
|
||||
if (verifyProcess)
|
||||
{
|
||||
Assert.IsTrue(
|
||||
WaitForProcess(ModuleProcess, expected: enable, timeoutMs),
|
||||
$"Process '{ModuleProcess}' should be {(enable ? "running" : "stopped")} after toggling.");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Activation shortcut ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Read the activation shortcut from the ShortcutControl's EditButton HelpText.</summary>
|
||||
public static Key[] ReadActivationShortcut(UITestBase testBase)
|
||||
{
|
||||
var card = testBase.Session.Find<Element>(By.AccessibilityId(ShortcutCardId), 5000);
|
||||
var editButton = card.Find<Element>(By.AccessibilityId("EditButton"), 5000);
|
||||
return ParseShortcutText(editButton.HelpText);
|
||||
}
|
||||
|
||||
/// <summary>Parse "Win + Ctrl + Shift + M" into a Key chord (note: "win" maps to <see cref="Key.LWin"/>).</summary>
|
||||
public static Key[] ParseShortcutText(string shortcutText)
|
||||
{
|
||||
var keys = new List<Key>();
|
||||
if (string.IsNullOrEmpty(shortcutText))
|
||||
{
|
||||
return keys.ToArray();
|
||||
}
|
||||
|
||||
foreach (var raw in shortcutText.Split(new[] { " + ", "+", " " }, StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var part = raw.Trim().ToLowerInvariant();
|
||||
Key? key = part switch
|
||||
{
|
||||
"win" or "windows" => Key.LWin,
|
||||
"ctrl" or "control" => Key.Ctrl,
|
||||
"shift" => Key.Shift,
|
||||
"alt" => Key.Alt,
|
||||
_ when part.Length == 1 && part[0] >= 'a' && part[0] <= 'z' =>
|
||||
(Key)Enum.Parse(typeof(Key), part.ToUpperInvariant()),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
if (key.HasValue)
|
||||
{
|
||||
keys.Add(key.Value);
|
||||
}
|
||||
}
|
||||
|
||||
return keys.ToArray();
|
||||
}
|
||||
|
||||
// ── Module window lifecycle ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>True when at least one of the module's windows is open.</summary>
|
||||
public static bool IsModuleUIOpen() => WindowsFinder.ListByApp(ModuleProcess).Count > 0;
|
||||
|
||||
/// <summary>Poll until the module UI reaches the requested presence.</summary>
|
||||
public static bool WaitForModuleUIState(bool shouldBeOpen, int timeoutMs = 5000, int pollMs = 100)
|
||||
{
|
||||
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (IsModuleUIOpen() == shouldBeOpen)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
Thread.Sleep(pollMs);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static bool WaitForModuleUI(int timeoutMs = 5000) => WaitForModuleUIState(true, timeoutMs);
|
||||
|
||||
public static bool WaitForModuleUIToDisappear(int timeoutMs = 5000) => WaitForModuleUIState(false, timeoutMs);
|
||||
|
||||
/// <summary>
|
||||
/// Send the activation chord, retrying until the module UI appears. The runner arms its keyboard
|
||||
/// hook asynchronously after the module is enabled, so the first chord is easily lost — settle
|
||||
/// first, then retry (see Recipe 4 / Pitfall 14).
|
||||
/// </summary>
|
||||
public static bool SendShortcutUntilVisible(UITestBase testBase, Key[] activationKeys, int attempts = 5, int perAttemptMs = 3000)
|
||||
{
|
||||
Thread.Sleep(1500); // let the just-enabled module register its global hotkey
|
||||
for (int i = 0; i < attempts; i++)
|
||||
{
|
||||
KeyboardHelper.SendKeys(activationKeys);
|
||||
if (WaitForModuleUI(perAttemptMs))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Activate the module via its shortcut and return a PROCESS-scoped session for its window(s).
|
||||
/// Process scope (<see cref="Session.FromProcess"/>) resolves controls across whichever of the
|
||||
/// module's windows owns them — the winappcli equivalent of the legacy <c>global: true</c> Find.
|
||||
/// </summary>
|
||||
public static Session ActivateModule(UITestBase testBase, Key[] activationKeys, string testName)
|
||||
{
|
||||
ClipboardHelper.Clear();
|
||||
|
||||
Assert.IsTrue(
|
||||
SendShortcutUntilVisible(testBase, activationKeys),
|
||||
$"Module UI should appear after the activation shortcut for {testName}: {string.Join(" + ", activationKeys)}");
|
||||
|
||||
return Session.FromProcess(ModuleProcess, PowerToysModule.PowerToysSettings, timeoutMS: 5000);
|
||||
}
|
||||
|
||||
/// <summary>Close the module UI if open (best-effort, tolerant — safe in a finally).</summary>
|
||||
public static void CloseModuleUI(UITestBase testBase)
|
||||
{
|
||||
if (!IsModuleUIOpen())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Prefer an in-UI Close button if the module has one; otherwise WM_CLOSE every window.
|
||||
// try { Session.FromProcess(ModuleProcess).Find<Element>(By.AccessibilityId("Button_Close"), 2000).Click(); } catch { }
|
||||
WindowControl.TryCloseByApp(ModuleProcess);
|
||||
}
|
||||
|
||||
// ── Utilities ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Poll for a process becoming present/absent (no built-in wait for this).</summary>
|
||||
public static bool WaitForProcess(string processName, bool expected, int timeoutMs)
|
||||
{
|
||||
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if ((System.Diagnostics.Process.GetProcessesByName(processName).Length > 0) == expected)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
Thread.Sleep(250);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Primary-monitor centre in PHYSICAL pixels — the right anchor for coordinate gestures (don't
|
||||
/// offset from the current cursor, which can be off-screen). Correct only when the test host is
|
||||
/// per-monitor DPI aware (add the app.manifest, Pitfall 12); otherwise the size is virtualized.
|
||||
/// </summary>
|
||||
public static (int X, int Y) ScreenCenter()
|
||||
{
|
||||
var size = System.Windows.Forms.SystemInformation.PrimaryMonitorSize;
|
||||
return (size.Width / 2, size.Height / 2);
|
||||
}
|
||||
}
|
||||
33
.github/skills/ui-tests-migration/templates/app.manifest
vendored
Normal file
33
.github/skills/ui-tests-migration/templates/app.manifest
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
TEMPLATE app.manifest for a .Next UI-test project.
|
||||
|
||||
ADD THIS ONLY for projects with COORDINATE-EXACT tests (mouse drag/click asserting on exact
|
||||
pixel/measurement values, e.g. Screen Ruler's Bounds "100 x 100"). Without PerMonitorV2 the test
|
||||
host is DPI-unaware and MouseHelper's SetCursorPos/GetCursorPos coordinates are virtualized by the
|
||||
display scale factor, so they no longer match the PHYSICAL pixels winappcli reports (a 99px drag
|
||||
measured ~149px on a 150% display).
|
||||
|
||||
Wire it into the csproj:
|
||||
<PropertyGroup>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
</PropertyGroup>
|
||||
|
||||
Replace __MODULE__ in the assemblyIdentity name.
|
||||
-->
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<assemblyIdentity version="1.0.0.0" name="__MODULE__.UITests.Next.app"/>
|
||||
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<!-- Windows 10+ feature support for unpackaged apps. -->
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||
</application>
|
||||
</compatibility>
|
||||
|
||||
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<windowsSettings>
|
||||
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
|
||||
</windowsSettings>
|
||||
</application>
|
||||
</assembly>
|
||||
70
.pipelines/InstallWinAppCli.ps1
Normal file
70
.pipelines/InstallWinAppCli.ps1
Normal file
@@ -0,0 +1,70 @@
|
||||
[CmdletBinding()]
|
||||
Param(
|
||||
# Target architecture: 'x64' or 'arm64'. Defaults to the pipeline's BuildPlatform variable.
|
||||
[string]$Platform = $env:BuildPlatform
|
||||
)
|
||||
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# Pinned to the winappcli version the UITestAutomation.Next harness is validated against. Using
|
||||
# the standalone CLI zip (rather than the MSIX / winget) keeps this working on agents that lack
|
||||
# the App Installer and avoids MSIX registration entirely.
|
||||
$Version = 'v0.3.2'
|
||||
|
||||
switch ($Platform)
|
||||
{
|
||||
'arm64'
|
||||
{
|
||||
$Asset = 'winappcli-arm64.zip'
|
||||
$ExpectedHash = 'dfe9d6eb70618665e4adcee989be8ecd076bfd387714a35a5b38597196fed093'
|
||||
}
|
||||
default
|
||||
{
|
||||
$Asset = 'winappcli-x64.zip'
|
||||
$ExpectedHash = '231373a4605ce7749172a70534ebab9305f91116e7f68d25cc73051372a6c579'
|
||||
}
|
||||
}
|
||||
|
||||
$DownloadUrl = "https://github.com/microsoft/winappCli/releases/download/$Version/$Asset"
|
||||
$ZipPath = Join-Path $env:Temp $Asset
|
||||
$InstallDir = Join-Path $env:Temp 'winappcli'
|
||||
|
||||
Write-Host "Downloading winappcli $Version ($Asset) from $DownloadUrl"
|
||||
Invoke-WebRequest -Uri $DownloadUrl -OutFile $ZipPath
|
||||
|
||||
# Verify the download against the published SHA256 before trusting it.
|
||||
$Hash = (Get-FileHash -Algorithm SHA256 $ZipPath).Hash
|
||||
if ($Hash -ne $ExpectedHash)
|
||||
{
|
||||
throw "$Asset has unexpected SHA256 hash: $Hash (expected $ExpectedHash)"
|
||||
}
|
||||
|
||||
# Fresh extract each run so a stale copy can't shadow the pinned version.
|
||||
if (Test-Path $InstallDir)
|
||||
{
|
||||
Remove-Item $InstallDir -Recurse -Force
|
||||
}
|
||||
Expand-Archive -Path $ZipPath -DestinationPath $InstallDir -Force
|
||||
|
||||
# Clear Mark-of-the-Web in case the agent applied it, so the CLI runs non-interactively.
|
||||
Get-ChildItem -Path $InstallDir -Recurse | Unblock-File -ErrorAction SilentlyContinue
|
||||
|
||||
$winapp = Get-ChildItem -Path $InstallDir -Recurse -Filter 'winapp.exe' | Select-Object -First 1 -ExpandProperty FullName
|
||||
if (-not $winapp)
|
||||
{
|
||||
throw "winapp.exe was not found after extracting $Asset to $InstallDir."
|
||||
}
|
||||
|
||||
Write-Host "winappcli installed at: $winapp"
|
||||
|
||||
# The harness (WinappCli.TryResolveExecutable) checks WINAPP_CLI_PATH first; also prepend the
|
||||
# folder to PATH so any other consumer in later steps resolves winapp.exe too.
|
||||
Write-Host "##vso[task.setvariable variable=WINAPP_CLI_PATH]$winapp"
|
||||
Write-Host "##vso[task.prependpath]$(Split-Path -Parent $winapp)"
|
||||
|
||||
& $winapp --version
|
||||
if ($LASTEXITCODE -ne 0)
|
||||
{
|
||||
throw "winapp.exe failed to run ('--version' exited with $LASTEXITCODE)."
|
||||
}
|
||||
@@ -1,15 +1,20 @@
|
||||
param(
|
||||
[Parameter()]
|
||||
[ValidateSet("Machine", "PerUser")]
|
||||
[string]$InstallMode = "Machine"
|
||||
[string]$InstallMode = "Machine",
|
||||
|
||||
# Folder that contains the PowerToys installer. Defaults to the build staging directory used
|
||||
# by the official-build path (installer downloaded via DownloadPipelineArtifact@2). The
|
||||
# full-build (buildNow) path passes the downloaded pipeline-artifact folder instead, since
|
||||
# the installer ships inside that build's own artifact.
|
||||
[Parameter()]
|
||||
[string]$ArtifactPath = $ENV:BUILD_ARTIFACTSTAGINGDIRECTORY
|
||||
)
|
||||
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
|
||||
# Get artifact path
|
||||
$ArtifactPath = $ENV:BUILD_ARTIFACTSTAGINGDIRECTORY
|
||||
if (-not $ArtifactPath) {
|
||||
throw "BUILD_ARTIFACTSTAGINGDIRECTORY environment variable not set"
|
||||
throw "Installer path not provided. Pass -ArtifactPath or set BUILD_ARTIFACTSTAGINGDIRECTORY."
|
||||
}
|
||||
|
||||
# Since we only download PowerToysSetup-*.exe files, we can directly find it
|
||||
|
||||
@@ -171,6 +171,11 @@ jobs:
|
||||
fetchTags: false
|
||||
fetchDepth: 1
|
||||
|
||||
# Checkout to surface a missing import before full build.
|
||||
- pwsh: |-
|
||||
& '.pipelines/verifyCommonProps.ps1' -sourceDir '$(build.sourcesdirectory)\src'
|
||||
displayName: Audit shared common props for CSharp projects in src sub-folder
|
||||
|
||||
- ${{ if eq(parameters.enableMsBuildCaching, true) }}:
|
||||
- pwsh: |-
|
||||
$MSBuildCacheParameters = ""
|
||||
@@ -464,11 +469,6 @@ jobs:
|
||||
flattenFolders: True
|
||||
OverWrite: True
|
||||
|
||||
# Check if all projects (located in src sub-folder) import common props
|
||||
- pwsh: |-
|
||||
& '.pipelines/verifyCommonProps.ps1' -sourceDir '$(build.sourcesdirectory)\src'
|
||||
displayName: Audit shared common props for CSharp projects in src sub-folder
|
||||
|
||||
# Check if deps.json files don't reference different dll versions.
|
||||
- pwsh: |-
|
||||
& '.pipelines/verifyDepsJsonLibraryVersions.ps1' -targetDir '$(build.sourcesdirectory)\$(BuildPlatform)\$(BuildConfiguration)'
|
||||
|
||||
@@ -90,15 +90,28 @@ jobs:
|
||||
reg add "HKLM\Software\Policies\Microsoft\Edge\WebView2\ReleaseChannels" /v PowerToys.exe /t REG_SZ /d "3"
|
||||
displayName: "Enable WebView2 Canary Channel"
|
||||
|
||||
- ${{ if ne(parameters.platform, 'arm64') }}:
|
||||
- download: current
|
||||
displayName: Download artifacts
|
||||
artifact: $(TestArtifactsName)
|
||||
patterns: |-
|
||||
**
|
||||
!**\*.pdb
|
||||
!**\*.lib
|
||||
- ${{ if eq(parameters.buildSource, 'buildNowSlim') }}:
|
||||
# buildNowSlim: the full build publishes the entire ~14 GB tree, but slim runs against the
|
||||
# installed product, so fetch only the installer + the staged test binaries from this run's
|
||||
# full-build artifact.
|
||||
# IMPORTANT: DownloadPipelineArtifact's itemPattern downloads a file that matches ANY pattern,
|
||||
# and '!' lines do NOT exclude. Per the task docs they "include files that don't match any
|
||||
# include pattern", so a '!**/*.pdb' line pulls in every non-pdb file — i.e. the whole product
|
||||
# tree (~14 GB). Use INCLUDE-ONLY patterns so only the installer + tests folder transfer.
|
||||
- task: DownloadPipelineArtifact@2
|
||||
displayName: Download artifacts (slim)
|
||||
inputs:
|
||||
buildType: 'current'
|
||||
artifactName: $(TestArtifactsName)
|
||||
targetPath: '$(Pipeline.Workspace)/$(TestArtifactsName)'
|
||||
patterns: |
|
||||
**/PowerToysSetup*.exe
|
||||
**/tests/**
|
||||
- ${{ else }}:
|
||||
# buildNow (whole tree, run in place) and the official path (small tests-only artifact) both
|
||||
# download the full named artifact via the Azure CLI ArtifactTool (bulk dedup, parallel). The
|
||||
# x64 CLI zip runs natively on x64 and under emulation on arm64, so one path serves every arch
|
||||
# and avoids the arm64 OOM the pipeline task hits on the large full-build artifact.
|
||||
- template: steps-download-artifacts-with-azure-cli.yml
|
||||
parameters:
|
||||
artifactName: $(TestArtifactsName)
|
||||
@@ -106,13 +119,20 @@ jobs:
|
||||
- template: steps-ensure-dotnet-version.yml
|
||||
parameters:
|
||||
sdk: true
|
||||
version: '9.0'
|
||||
version: '10.0'
|
||||
|
||||
- pwsh: |-
|
||||
& '$(build.sourcesdirectory)\.pipelines\InstallWinAppDriver.ps1'
|
||||
displayName: Download and install WinAppDriver
|
||||
|
||||
- ${{ if ne(parameters.buildSource, 'buildNow') }}:
|
||||
# winappcli (winapp.exe) powers the Microsoft.PowerToys.UITest.Next harness and isn't baked
|
||||
# into the agent image yet. winget / App Installer isn't available on these agents, so download
|
||||
# the pinned standalone CLI from its GitHub release. Drop this step once the CLI is pre-staged.
|
||||
- pwsh: |-
|
||||
& '$(build.sourcesdirectory)\.pipelines\InstallWinAppCli.ps1' -Platform '$(BuildPlatform)'
|
||||
displayName: Download and install winappcli (winapp.exe)
|
||||
|
||||
- ${{ if and(ne(parameters.buildSource, 'buildNow'), ne(parameters.buildSource, 'buildNowSlim')) }}:
|
||||
- task: DownloadPipelineArtifact@2
|
||||
inputs:
|
||||
buildType: 'specific'
|
||||
@@ -133,7 +153,7 @@ jobs:
|
||||
patterns: |
|
||||
**/PowerToysSetup*.exe
|
||||
|
||||
- ${{ if ne(parameters.buildSource, 'buildNow') }}:
|
||||
- ${{ if and(ne(parameters.buildSource, 'buildNow'), ne(parameters.buildSource, 'buildNowSlim')) }}:
|
||||
- ${{ if eq(parameters.installMode, 'peruser') }}:
|
||||
- pwsh: |-
|
||||
& "$(build.sourcesdirectory)\.pipelines\installPowerToys.ps1" -InstallMode "PerUser"
|
||||
@@ -144,12 +164,137 @@ jobs:
|
||||
& "$(build.sourcesdirectory)\.pipelines\installPowerToys.ps1" -InstallMode "Machine"
|
||||
displayName: Install PowerToys (Machine-Level)
|
||||
|
||||
# buildNowSlim: the full build's installer was pulled into the test-artifact folder above (instead
|
||||
# of the whole ~14 GB tree), so install it and run the tests against the installed product — the
|
||||
# same model as the official path. Available on every arch.
|
||||
- ${{ if eq(parameters.buildSource, 'buildNowSlim') }}:
|
||||
- pwsh: |-
|
||||
& "$(build.sourcesdirectory)\.pipelines\installPowerToys.ps1" -InstallMode "Machine" -ArtifactPath "$(Pipeline.Workspace)\$(TestArtifactsName)"
|
||||
displayName: Install PowerToys (Machine-Level)
|
||||
|
||||
- ${{ if ne(parameters.platform, 'arm64') }}:
|
||||
- task: ScreenResolutionUtility@1
|
||||
inputs:
|
||||
displaySettings: 'optimal'
|
||||
|
||||
- script: |
|
||||
dotnet test $(Build.SourcesDirectory)\src\modules\fancyzones\FancyZones.UITests\FancyZones.UITests.csproj --no-build -c $(BuildConfiguration) -p:Platform=$(BuildPlatform)
|
||||
dotnet test $(Build.SourcesDirectory)\src\modules\fancyzones\FancyZonesEditor.UITests\FancyZonesEditor.UITests.csproj --no-build -c $(BuildConfiguration) -p:Platform=$(BuildPlatform)
|
||||
# Start WinAppDriver once for the whole job — WinAppDriver's documented CI pattern
|
||||
# (https://github.com/microsoft/WinAppDriver/blob/master/Docs/CI_AzureDevOps.md). Launching it
|
||||
# detached gives it its own console whose stdin blocks, so it stays alive for the run instead of
|
||||
# reading EOF and exiting the moment it starts listening (the failure mode when a test host launches
|
||||
# it as a child). The legacy UITest harness reuses an already-listening instance rather than
|
||||
# relaunching it per test, so this removes the per-assembly launch cost. The winappcli-based .Next
|
||||
# tests don't use WinAppDriver. Best-effort: if the pre-start fails, each assembly still launches its own.
|
||||
- pwsh: |
|
||||
$winapp = "C:\Program Files (x86)\Windows Application Driver\WinAppDriver.exe"
|
||||
if (Test-Path $winapp) {
|
||||
Start-Process -FilePath $winapp
|
||||
|
||||
$deadline = (Get-Date).AddSeconds(30)
|
||||
$ready = $false
|
||||
while (-not $ready -and (Get-Date) -lt $deadline) {
|
||||
try {
|
||||
$client = [System.Net.Sockets.TcpClient]::new()
|
||||
$client.Connect('127.0.0.1', 4723)
|
||||
$ready = $client.Connected
|
||||
$client.Close()
|
||||
} catch {
|
||||
Start-Sleep -Milliseconds 500
|
||||
}
|
||||
}
|
||||
|
||||
if ($ready) {
|
||||
Write-Host 'WinAppDriver is listening on 127.0.0.1:4723.'
|
||||
} else {
|
||||
Write-Host "##vso[task.logissue type=warning]WinAppDriver did not start listening on :4723 within 30s; tests will launch it themselves."
|
||||
}
|
||||
} else {
|
||||
Write-Host "##vso[task.logissue type=warning]WinAppDriver not found at $winapp; tests will launch it themselves."
|
||||
}
|
||||
displayName: Start WinAppDriver (shared, persistent)
|
||||
|
||||
- pwsh: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$artifactRoot = "$(Pipeline.Workspace)\$(TestArtifactsName)"
|
||||
if (-not (Test-Path $artifactRoot)) {
|
||||
Write-Host "##vso[task.logissue type=error]UI test artifact not found: $artifactRoot"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# uiTestModules is a template parameter; flatten it to a delimited string for the script.
|
||||
$modulesRaw = '${{ join(';', parameters.uiTestModules) }}'
|
||||
$modules = @()
|
||||
if (-not [string]::IsNullOrWhiteSpace($modulesRaw)) {
|
||||
$modules = $modulesRaw -split ';' | ForEach-Object { $_.Trim() } | Where-Object { $_ }
|
||||
}
|
||||
|
||||
# Each UI test project is a Microsoft.Testing.Platform app; its entry assembly is paired
|
||||
# with a *.runtimeconfig.json. Recurse under the staged 'tests' folders (tolerates TFM/RID subfolders).
|
||||
$entries = Get-ChildItem -Path $artifactRoot -Filter '*.runtimeconfig.json' -File -Recurse -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Name -like '*UITests*' -and $_.FullName -match '\\tests\\' }
|
||||
if ($modules.Count -gt 0) {
|
||||
$entries = $entries | Where-Object { $n = $_.Name; ($modules | Where-Object { $n -like "*$_*" }).Count -gt 0 }
|
||||
}
|
||||
|
||||
# Run each test assembly once (a project reference can copy a runner into a sibling's output).
|
||||
$entries = $entries | Sort-Object FullName | Group-Object Name | ForEach-Object { $_.Group[0] }
|
||||
|
||||
if (-not $entries) {
|
||||
Write-Host "##vso[task.logissue type=error]No UI test runners matched (modules: '$modulesRaw') under $artifactRoot"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$resultsDir = "$(Common.TestResultsDirectory)"
|
||||
New-Item -ItemType Directory -Path $resultsDir -Force | Out-Null
|
||||
|
||||
$failed = 0
|
||||
foreach ($rc in ($entries | Sort-Object FullName -Unique)) {
|
||||
$base = $rc.Name -replace '\.runtimeconfig\.json$', ''
|
||||
$dir = $rc.DirectoryName
|
||||
$exe = Join-Path $dir "$base.exe"
|
||||
$dll = Join-Path $dir "$base.dll"
|
||||
Write-Host "##[group]Run UI tests: $base"
|
||||
Push-Location $dir
|
||||
try {
|
||||
if (Test-Path $exe) {
|
||||
& $exe --report-trx --results-directory $resultsDir
|
||||
} elseif (Test-Path $dll) {
|
||||
& dotnet $dll --report-trx --results-directory $resultsDir
|
||||
} else {
|
||||
Write-Warning "No runner (exe/dll) found for $base in $dir"
|
||||
}
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Warning "UI tests reported failures for $base (exit $LASTEXITCODE)"
|
||||
$failed++
|
||||
}
|
||||
} finally {
|
||||
Pop-Location
|
||||
Write-Host "##[endgroup]"
|
||||
}
|
||||
}
|
||||
|
||||
if ($failed -gt 0) {
|
||||
Write-Host "##vso[task.logissue type=error]$failed UI test project(s) reported failures."
|
||||
exit 1
|
||||
}
|
||||
displayName: "Run UI Tests"
|
||||
# Expose 'platform' as an environment variable so the harness's EnvironmentConfig.IsInPipeline
|
||||
# is true and it captures failure media (screenshots / recording / logs). The legacy VSTest task
|
||||
# set `env: { platform: $(TestPlatform) }`; the MTP migration to this pwsh step dropped it.
|
||||
env:
|
||||
platform: $(TestPlatform)
|
||||
|
||||
- task: PublishTestResults@2
|
||||
displayName: "Publish UI Test Results"
|
||||
condition: always()
|
||||
inputs:
|
||||
testResultsFormat: VSTest
|
||||
testResultsFiles: '$(Common.TestResultsDirectory)/**/*.trx'
|
||||
mergeTestResults: true
|
||||
failTaskOnFailedTests: false
|
||||
|
||||
# Stop the shared WinAppDriver (paired with the start step above) so it doesn't linger on the
|
||||
# self-hosted agent between jobs. Best-effort and always runs.
|
||||
- pwsh: |
|
||||
Get-Process -Name 'WinAppDriver' -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
|
||||
displayName: Stop WinAppDriver
|
||||
condition: always()
|
||||
|
||||
@@ -26,6 +26,7 @@ parameters:
|
||||
values:
|
||||
- latestMainOfficialBuild
|
||||
- buildNow
|
||||
- buildNowSlim
|
||||
- specificBuildId
|
||||
- name: specificBuildId
|
||||
type: string
|
||||
@@ -37,18 +38,21 @@ parameters:
|
||||
|
||||
stages:
|
||||
- ${{ each platform in parameters.buildPlatforms }}:
|
||||
# Full build path: build PowerToys + UI tests + run tests
|
||||
- ${{ if eq(parameters.buildSource, 'buildNow') }}:
|
||||
# Full build path: build PowerToys + UI tests + run tests.
|
||||
# buildNow downloads the whole build and runs in place; buildNowSlim downloads only the installer
|
||||
# from that same full build and installs it. Both require the full build, so they share this path.
|
||||
- ${{ if or(eq(parameters.buildSource, 'buildNow'), eq(parameters.buildSource, 'buildNowSlim')) }}:
|
||||
- template: pipeline-ui-tests-full-build.yml
|
||||
parameters:
|
||||
platform: ${{ platform }}
|
||||
buildSource: ${{ parameters.buildSource }}
|
||||
enableMsBuildCaching: ${{ parameters.enableMsBuildCaching }}
|
||||
useVSPreview: ${{ parameters.useVSPreview }}
|
||||
useLatestWebView2: ${{ parameters.useLatestWebView2 }}
|
||||
uiTestModules: ${{ parameters.uiTestModules }}
|
||||
|
||||
# Official build path: build UI tests only + download official build + run tests
|
||||
- ${{ if ne(parameters.buildSource, 'buildNow') }}:
|
||||
# Official build path: build UI tests only + download official build + run tests
|
||||
- ${{ if and(ne(parameters.buildSource, 'buildNow'), ne(parameters.buildSource, 'buildNowSlim')) }}:
|
||||
- template: pipeline-ui-tests-official-build.yml
|
||||
parameters:
|
||||
platform: ${{ platform }}
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
parameters:
|
||||
- name: platform
|
||||
type: string
|
||||
# buildNow = download the whole build artifact and run in place; buildNowSlim = download only the
|
||||
# installer from this same full build and install it. Both build the full product in this template.
|
||||
- name: buildSource
|
||||
type: string
|
||||
default: buildNow
|
||||
- name: enableMsBuildCaching
|
||||
type: boolean
|
||||
default: false
|
||||
@@ -53,7 +58,7 @@ stages:
|
||||
platform: x64Win10
|
||||
configuration: Release
|
||||
useLatestWebView2: ${{ parameters.useLatestWebView2 }}
|
||||
buildSource: 'buildNow'
|
||||
buildSource: ${{ parameters.buildSource }}
|
||||
uiTestModules: ${{ parameters.uiTestModules }}
|
||||
|
||||
- stage: Test_x64Win11_FullBuild
|
||||
@@ -65,7 +70,7 @@ stages:
|
||||
platform: x64Win11
|
||||
configuration: Release
|
||||
useLatestWebView2: ${{ parameters.useLatestWebView2 }}
|
||||
buildSource: 'buildNow'
|
||||
buildSource: ${{ parameters.buildSource }}
|
||||
uiTestModules: ${{ parameters.uiTestModules }}
|
||||
|
||||
- ${{ if ne(parameters.platform, 'x64') }}:
|
||||
@@ -78,5 +83,5 @@ stages:
|
||||
platform: ${{ parameters.platform }}
|
||||
configuration: Release
|
||||
useLatestWebView2: ${{ parameters.useLatestWebView2 }}
|
||||
buildSource: 'buildNow'
|
||||
buildSource: ${{ parameters.buildSource }}
|
||||
uiTestModules: ${{ parameters.uiTestModules }}
|
||||
|
||||
@@ -93,7 +93,7 @@ For complete details, see [Build Guidelines](tools/build/BUILD-GUIDELINES.md).
|
||||
|------|--------------|-------|
|
||||
| Unit Tests | Standard dev environment | None |
|
||||
| UI Tests | WinAppDriver v1.2.1, Developer Mode | Install from [WinAppDriver releases](https://github.com/microsoft/WinAppDriver/releases/tag/v1.2.1) |
|
||||
| Fuzz Tests | OneFuzz, .NET 8 | See [Fuzzing Tests](doc/devdocs/tools/fuzzingtesting.md) |
|
||||
| Fuzz Tests | OneFuzz, .NET 10 | See [Fuzzing Tests](doc/devdocs/tools/fuzzingtesting.md) |
|
||||
|
||||
### Test discipline
|
||||
|
||||
|
||||
@@ -66,7 +66,10 @@
|
||||
<LanguageStandard>stdcpplatest</LanguageStandard>
|
||||
<BuildStlModules>false</BuildStlModules>
|
||||
<!-- TODO: _SILENCE_STDEXT_ARR_ITERS_DEPRECATION_WARNING for compatibility with VS 17.8. Check if we can remove. -->
|
||||
<PreprocessorDefinitions>_SILENCE_STDEXT_ARR_ITERS_DEPRECATION_WARNING;_UNICODE;UNICODE;%(PreprocessorDefinitions)</PreprocessorDefinitions>
|
||||
<!-- TODO: _SILENCE_EXPERIMENTAL_COROUTINE_DEPRECATION_WARNINGS for VS 2026 (MSVC 14.51+). The STL turned
|
||||
<experimental/coroutine> into a hard error (STL1011), and C++/WinRT's base.h still falls back to it when
|
||||
__cpp_lib_coroutine isn't defined at include time. Remove once C++/WinRT no longer references the experimental header. -->
|
||||
<PreprocessorDefinitions>_SILENCE_STDEXT_ARR_ITERS_DEPRECATION_WARNING;_SILENCE_EXPERIMENTAL_COROUTINE_DEPRECATION_WARNINGS;_UNICODE;UNICODE;%(PreprocessorDefinitions)</PreprocessorDefinitions>
|
||||
<!-- CLR + CFG are not compatible >:{ -->
|
||||
<ControlFlowGuard Condition="'$(CLRSupport)' == ''">Guard</ControlFlowGuard>
|
||||
<DebugInformationFormat Condition="'%(ControlFlowGuard)' == 'Guard'">ProgramDatabase</DebugInformationFormat>
|
||||
|
||||
@@ -99,6 +99,7 @@
|
||||
<PackageVersion Include="ReverseMarkdown" Version="4.1.0" />
|
||||
<PackageVersion Include="RtfPipe" Version="2.0.7677.4303" />
|
||||
<PackageVersion Include="ScipBe.Common.Office.OneNote" Version="3.0.1" />
|
||||
<PackageVersion Include="ScreenRecorderLib" Version="6.6.0" />
|
||||
<PackageVersion Include="SharpCompress" Version="0.37.2" />
|
||||
<PackageVersion Include="Shmuelie.WinRTServer" Version="2.1.1" />
|
||||
<!-- Don't update SkiaSharp.Views.WinUI to version 3.* branch as this brakes the HexBox control in Registry Preview. -->
|
||||
|
||||
@@ -1589,6 +1589,7 @@ SOFTWARE.
|
||||
- OpenAI
|
||||
- ReverseMarkdown
|
||||
- ScipBe.Common.Office.OneNote
|
||||
- ScreenRecorderLib
|
||||
- SharpCompress
|
||||
- Shmuelie.WinRTServer
|
||||
- SkiaSharp.Views.WinUI
|
||||
|
||||
@@ -9,11 +9,11 @@
|
||||
<Platform Solution="*|ARM64" Project="ARM64" />
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
</Project>
|
||||
<Project Path="src/common/Common.UI/Common.UI.csproj">
|
||||
<Project Path="src/common/Common.UI.Controls/Common.UI.Controls.csproj">
|
||||
<Platform Solution="*|ARM64" Project="ARM64" />
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
</Project>
|
||||
<Project Path="src/common/Common.UI.Controls/Common.UI.Controls.csproj">
|
||||
<Project Path="src/common/Common.UI/Common.UI.csproj">
|
||||
<Platform Solution="*|ARM64" Project="ARM64" />
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
</Project>
|
||||
@@ -54,10 +54,14 @@
|
||||
<Platform Solution="*|ARM64" Project="ARM64" />
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
</Project>
|
||||
<Project Path="src/common/UITestAutomation.Next/UITestAutomation.Next.csproj">
|
||||
<Platform Solution="*|ARM64" Project="ARM64" />
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
</Project>
|
||||
<Project Path="src/common/UnitTests-CommonLib/UnitTests-CommonLib.vcxproj" Id="1a066c63-64b3-45f8-92fe-664e1cce8077" />
|
||||
<Project Path="src/common/UnitTests-CommonUtils/UnitTests-CommonUtils.vcxproj" Id="8b5cfb38-ccba-40a8-ad7a-89c57b070884" />
|
||||
<Project Path="src/common/updating/updating.vcxproj" Id="17da04df-e393-4397-9cf0-84dabe11032e" />
|
||||
<Project Path="src/common/updating/UnitTests/UpdatingUnitTests.vcxproj" Id="a1b2c3d4-e5f6-7890-abcd-ef1234567890" />
|
||||
<Project Path="src/common/updating/updating.vcxproj" Id="17da04df-e393-4397-9cf0-84dabe11032e" />
|
||||
<Project Path="src/common/version/version.vcxproj" Id="cc6e41ac-8174-4e8a-8d22-85dd7f4851df" />
|
||||
</Folder>
|
||||
<Folder Name="/common/interop/">
|
||||
@@ -190,6 +194,10 @@
|
||||
<Platform Solution="*|ARM64" Project="ARM64" />
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
</Project>
|
||||
<Project Path="src/modules/colorPicker/ColorPicker.UITests/ColorPicker.UITests.csproj">
|
||||
<Platform Solution="*|ARM64" Project="ARM64" />
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
</Project>
|
||||
</Folder>
|
||||
<Folder Name="/modules/CommandPalette/">
|
||||
<Project Path="src/modules/cmdpal/CmdPalKeyboardService/CmdPalKeyboardService.vcxproj" Id="5f63c743-f6ce-4dba-a200-2b3f8a14e8c2" />
|
||||
@@ -200,11 +208,11 @@
|
||||
</Project>
|
||||
</Folder>
|
||||
<Folder Name="/modules/CommandPalette/Built-in Extensions/">
|
||||
<Project Path="src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Microsoft.CmdPal.Ext.Apps.csproj">
|
||||
<Project Path="src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Actions/Microsoft.CmdPal.Ext.Actions.csproj">
|
||||
<Platform Solution="*|ARM64" Project="ARM64" />
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
</Project>
|
||||
<Project Path="src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Actions/Microsoft.CmdPal.Ext.Actions.csproj">
|
||||
<Project Path="src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Microsoft.CmdPal.Ext.Apps.csproj">
|
||||
<Platform Solution="*|ARM64" Project="ARM64" />
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
</Project>
|
||||
@@ -718,11 +726,11 @@
|
||||
</Project>
|
||||
</Folder>
|
||||
<Folder Name="/modules/PowerDisplay/">
|
||||
<Project Path="src/modules/powerdisplay/PowerDisplay.Models/PowerDisplay.Models.csproj">
|
||||
<Project Path="src/modules/powerdisplay/PowerDisplay.Lib/PowerDisplay.Lib.csproj">
|
||||
<Platform Solution="*|ARM64" Project="ARM64" />
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
</Project>
|
||||
<Project Path="src/modules/powerdisplay/PowerDisplay.Lib/PowerDisplay.Lib.csproj">
|
||||
<Project Path="src/modules/powerdisplay/PowerDisplay.Models/PowerDisplay.Models.csproj">
|
||||
<Platform Solution="*|ARM64" Project="ARM64" />
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
</Project>
|
||||
@@ -755,6 +763,10 @@
|
||||
<Platform Solution="*|ARM64" Project="ARM64" />
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
</Project>
|
||||
<Project Path="src/modules/MeasureTool/Tests/ScreenRuler.UITests.Next/ScreenRuler.UITests.Next.csproj">
|
||||
<Platform Solution="*|ARM64" Project="ARM64" />
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
</Project>
|
||||
</Folder>
|
||||
<Folder Name="/modules/MouseWithoutBorders/">
|
||||
<Project Path="src/modules/MouseWithoutBorders/App/Helper/MouseWithoutBordersHelper.csproj">
|
||||
@@ -1095,6 +1107,10 @@
|
||||
<Platform Solution="*|ARM64" Project="ARM64" />
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
</Project>
|
||||
<Project Path="src/settings-ui/Settings.UITests/Settings.UITests.csproj">
|
||||
<Platform Solution="*|ARM64" Project="ARM64" />
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
</Project>
|
||||
</Folder>
|
||||
<Folder Name="/Solution Items/">
|
||||
<File Path=".vsconfig" />
|
||||
@@ -1126,14 +1142,14 @@
|
||||
<BuildDependency Project="src/modules/fancyzones/editor/FancyZonesEditor/FancyZonesEditor.csproj" />
|
||||
<BuildDependency Project="src/modules/fancyzones/FancyZonesLib/FancyZonesLib.vcxproj" />
|
||||
<BuildDependency Project="src/modules/fancyzones/FancyZonesModuleInterface/FancyZonesModuleInterface.vcxproj" />
|
||||
<BuildDependency Project="src/modules/GrabAndMove/GrabAndMove/GrabAndMove.vcxproj" />
|
||||
<BuildDependency Project="src/modules/GrabAndMove/GrabAndMoveModuleInterface/GrabAndMoveModuleInterface.vcxproj" />
|
||||
<BuildDependency Project="src/modules/imageresizer/dll/ImageResizerExt.vcxproj" />
|
||||
<BuildDependency Project="src/modules/imageresizer/ui/ImageResizerUI.csproj" />
|
||||
<BuildDependency Project="src/modules/keyboardmanager/dll/KeyboardManager.vcxproj" />
|
||||
<BuildDependency Project="src/modules/launcher/Microsoft.Launcher/Microsoft.Launcher.vcxproj" />
|
||||
<BuildDependency Project="src/modules/LightSwitch/LightSwitchModuleInterface/LightSwitchModuleInterface.vcxproj" />
|
||||
<BuildDependency Project="src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj" />
|
||||
<BuildDependency Project="src/modules/GrabAndMove/GrabAndMoveModuleInterface/GrabAndMoveModuleInterface.vcxproj" />
|
||||
<BuildDependency Project="src/modules/GrabAndMove/GrabAndMove/GrabAndMove.vcxproj" />
|
||||
<BuildDependency Project="src/modules/powerrename/dll/PowerRenameExt.vcxproj" />
|
||||
<BuildDependency Project="src/modules/powerrename/lib/PowerRenameLib.vcxproj" />
|
||||
<BuildDependency Project="src/modules/previewpane/Common/PreviewHandlerCommon.csproj" />
|
||||
|
||||
@@ -28,8 +28,8 @@ Create a new test project within your module folder. Ensure the project name fol
|
||||
|
||||
### Step 2: Configure the Project
|
||||
|
||||
1. Set up a `.NET 8 (Windows)` project
|
||||
- Note: OneFuzz currently supports only .NET 8 projects. The Fuzz team is working on .NET 9 support.
|
||||
1. Set up a `.NET 10 (Windows)` project
|
||||
- Note: OneFuzz's .NET fuzzing is runtime-agnostic (".NET Core targets are preferred") and keys off the build drop directory, so PowerToys fuzz projects target net10 like the rest of the repo. Older guidance pinned .NET 8; that is no longer required.
|
||||
|
||||
2. Add the required files to your fuzzing test project:
|
||||
- Create fuzzing test code
|
||||
@@ -65,7 +65,7 @@ The `OneFuzzConfig.json` file provides critical information for deploying fuzzin
|
||||
"targetName": "YourModule",
|
||||
"jobDependencies": {
|
||||
"binaries": [
|
||||
"PowerToys\\x64\\Debug\\tests\\YourModule.FuzzTests\\net8.0-windows10.0.19041.0\\**"
|
||||
"PowerToys\\x64\\Debug\\tests\\YourModule.FuzzTests\\net10.0-windows10.0.26100.0\\**"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Some items may be set in Directory.Build.props in root -->
|
||||
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<!-- OneFuzz does not currently support testing with .NET 9.
|
||||
As a temporary workaround, create a .NET 8 project and use file links
|
||||
to include the code that needs testing. -->
|
||||
<!-- Fuzz test projects pin their target framework here so it can be managed
|
||||
independently of the main product TFM (Common.Dotnet.CsWinRT.props). This
|
||||
was historically .NET 8 because OneFuzz did not support newer runtimes.
|
||||
Per the current OneFuzz .NET fuzzing docs the service is runtime-agnostic
|
||||
(".NET Core targets are preferred") and keys off the build drop directory,
|
||||
so the fuzz projects now track net10 like the rest of the repo. -->
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0-windows10.0.26100.0</TargetFramework>
|
||||
<TargetFramework>net10.0-windows10.0.26100.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<!--
|
||||
|
||||
49
src/common/UITestAutomation.Next/By.cs
Normal file
49
src/common/UITestAutomation.Next/By.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.PowerToys.UITest.Next;
|
||||
|
||||
/// <summary>
|
||||
/// Selector used to locate elements via winappcli. winappcli has its own selector grammar
|
||||
/// (semantic slugs, plain text search) so this type maps onto the CLI's argument shape
|
||||
/// rather than mimicking Selenium's <c>By</c>.
|
||||
/// </summary>
|
||||
public sealed class By
|
||||
{
|
||||
public enum Kind
|
||||
{
|
||||
/// <summary>Plain-text search against Name or AutomationId (case-insensitive substring).</summary>
|
||||
Text,
|
||||
|
||||
/// <summary>Stable AutomationId, when the developer set one.</summary>
|
||||
AutomationId,
|
||||
|
||||
/// <summary>A semantic slug (e.g., <c>btn-close-d1a0</c>) printed by <c>inspect</c>/<c>search</c>.</summary>
|
||||
Slug,
|
||||
}
|
||||
|
||||
public Kind Selector { get; }
|
||||
|
||||
public string Value { get; }
|
||||
|
||||
private By(Kind kind, string value)
|
||||
{
|
||||
Selector = kind;
|
||||
Value = value;
|
||||
}
|
||||
|
||||
/// <summary>Plain-text search; what you'd type into <c>winapp ui search "<text>"</c>.</summary>
|
||||
public static By Name(string name) => new(Kind.Text, name);
|
||||
|
||||
/// <summary>Look up by stable AutomationId (winappcli also accepts these as selectors).</summary>
|
||||
public static By AccessibilityId(string id) => new(Kind.AutomationId, id);
|
||||
|
||||
/// <inheritdoc cref="AccessibilityId(string)"/>
|
||||
public static By Id(string id) => new(Kind.AutomationId, id);
|
||||
|
||||
/// <summary>Direct slug selector (e.g., <c>btn-colorpicker-b415</c>) as printed by inspect/search.</summary>
|
||||
public static By Slug(string slug) => new(Kind.Slug, slug);
|
||||
|
||||
public override string ToString() => $"{Selector}={Value}";
|
||||
}
|
||||
89
src/common/UITestAutomation.Next/ClipboardHelper.cs
Normal file
89
src/common/UITestAutomation.Next/ClipboardHelper.cs
Normal file
@@ -0,0 +1,89 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using FormsClipboard = System.Windows.Forms.Clipboard;
|
||||
|
||||
namespace Microsoft.PowerToys.UITest.Next;
|
||||
|
||||
/// <summary>
|
||||
/// Clipboard helpers that always execute on an STA thread (<see cref="FormsClipboard"/>
|
||||
/// requires it). Tolerant — every method swallows clipboard errors and returns a default,
|
||||
/// so callers can use them in test <c>finally</c> blocks without worrying about masking
|
||||
/// the real failure.
|
||||
/// </summary>
|
||||
public static class ClipboardHelper
|
||||
{
|
||||
/// <summary>Return the current clipboard text, or <see cref="string.Empty"/> if none / on error.</summary>
|
||||
public static string GetText() => RunSTA(() => FormsClipboard.ContainsText() ? FormsClipboard.GetText() : string.Empty) ?? string.Empty;
|
||||
|
||||
/// <summary>Clear the clipboard. Returns true on success, false on error.</summary>
|
||||
public static bool Clear() => RunSTA(() => { FormsClipboard.Clear(); return true; });
|
||||
|
||||
/// <summary>Set the clipboard text. Returns true on success, false on error.</summary>
|
||||
public static bool SetText(string value) => RunSTA(() => { FormsClipboard.SetText(value); return true; });
|
||||
|
||||
/// <summary>
|
||||
/// Poll the clipboard up to <paramref name="timeoutMS"/> for the first non-empty text
|
||||
/// different from <paramref name="ignoredValue"/>. Returns <see cref="string.Empty"/> on
|
||||
/// timeout. Use when you've just cleared the clipboard and are waiting for an external
|
||||
/// app (e.g. ColorPicker on click) to write into it.
|
||||
/// </summary>
|
||||
public static string WaitForText(string ignoredValue = "", int timeoutMS = 3_000, int pollIntervalMS = 100)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + TimeSpan.FromMilliseconds(timeoutMS);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
var text = GetText();
|
||||
if (!string.IsNullOrEmpty(text) && text != ignoredValue)
|
||||
{
|
||||
return text;
|
||||
}
|
||||
|
||||
Thread.Sleep(pollIntervalMS);
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private static T? RunSTA<T>(Func<T> body, int maxAttempts = 10, int retryDelayMS = 100)
|
||||
{
|
||||
T? result = default;
|
||||
try
|
||||
{
|
||||
var thread = new Thread(() =>
|
||||
{
|
||||
for (var attempt = 1; attempt <= maxAttempts; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
result = body();
|
||||
return;
|
||||
}
|
||||
catch when (attempt < maxAttempts)
|
||||
{
|
||||
// The clipboard is a single shared resource: OpenClipboard fails transiently
|
||||
// while another process still holds it open — very common right after an app
|
||||
// writes data (e.g. the Measure Tool committing a measurement on click, which
|
||||
// itself bails silently if OpenClipboard fails). A single-shot attempt surfaces
|
||||
// that as a false empty/failure, so wait a beat and retry instead of giving up.
|
||||
Console.WriteLine($"[clipboard] operation blocked (clipboard locked); retry {attempt}/{maxAttempts}");
|
||||
Thread.Sleep(retryDelayMS);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Final attempt also failed — leave result at its default (null/false/empty).
|
||||
}
|
||||
}
|
||||
});
|
||||
thread.SetApartmentState(ApartmentState.STA);
|
||||
thread.Start();
|
||||
thread.Join(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
131
src/common/UITestAutomation.Next/DisplayHelper.cs
Normal file
131
src/common/UITestAutomation.Next/DisplayHelper.cs
Normal file
@@ -0,0 +1,131 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Windows.Forms;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace Microsoft.PowerToys.UITest.Next;
|
||||
|
||||
/// <summary>
|
||||
/// Display-mode helpers used only by the pipeline path of <see cref="UITestBase"/>: pin the primary
|
||||
/// display to a known resolution so coordinate-sensitive tests are deterministic in CI, and dump the
|
||||
/// monitor topology for post-mortem diagnostics. Native because winappcli exposes no display API.
|
||||
/// </summary>
|
||||
public static class DisplayHelper
|
||||
{
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
||||
private static extern int EnumDisplaySettings(string? lpszDeviceName, int iModeNum, ref DEVMODE lpDevMode);
|
||||
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
||||
private static extern int ChangeDisplaySettings(ref DEVMODE lpDevMode, int dwflags);
|
||||
|
||||
private const int ENUM_CURRENT_SETTINGS = -1;
|
||||
private const int CDS_TEST = 0x00000002;
|
||||
private const int CDS_UPDATEREGISTRY = 0x00000001;
|
||||
private const int DISP_CHANGE_SUCCESSFUL = 0;
|
||||
private const int DM_PELSWIDTH = 0x00080000;
|
||||
private const int DM_PELSHEIGHT = 0x00100000;
|
||||
|
||||
/// <summary>
|
||||
/// Pin the primary display to <paramref name="width"/> x <paramref name="height"/>. No-op when
|
||||
/// already at that resolution. Best-effort — swallows failures because a CI agent may disallow
|
||||
/// display-mode changes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Unlike the legacy harness (which left <c>dmFields</c> unset), this reads the current mode via
|
||||
/// <c>EnumDisplaySettings(ENUM_CURRENT_SETTINGS)</c> and sets
|
||||
/// <c>DM_PELSWIDTH | DM_PELSHEIGHT</c> — the documented, reliable way to request a resolution
|
||||
/// change.
|
||||
/// </remarks>
|
||||
public static void NormalizeResolution(int width, int height)
|
||||
{
|
||||
try
|
||||
{
|
||||
var primary = Screen.PrimaryScreen;
|
||||
if (primary is not null && primary.Bounds.Width == width && primary.Bounds.Height == height)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var devMode = default(DEVMODE);
|
||||
devMode.DmDeviceName = new string('\0', 32);
|
||||
devMode.DmFormName = new string('\0', 32);
|
||||
devMode.DmSize = (short)Marshal.SizeOf<DEVMODE>();
|
||||
|
||||
if (EnumDisplaySettings(null, ENUM_CURRENT_SETTINGS, ref devMode) == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
devMode.DmPelsWidth = width;
|
||||
devMode.DmPelsHeight = height;
|
||||
devMode.DmFields = DM_PELSWIDTH | DM_PELSHEIGHT;
|
||||
|
||||
if (ChangeDisplaySettings(ref devMode, CDS_TEST) == DISP_CHANGE_SUCCESSFUL)
|
||||
{
|
||||
ChangeDisplaySettings(ref devMode, CDS_UPDATEREGISTRY);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Resolution normalization is a CI nicety, not a hard requirement.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Write the connected-monitor topology to the test log (and console) for diagnostics.</summary>
|
||||
public static void LogMonitors(TestContext? testContext = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (var m in MonitorInfo.GetAll())
|
||||
{
|
||||
var line = $"Monitor '{m.DeviceName}': {m.Width}x{m.Height} at ({m.Left},{m.Top}) primary={m.IsPrimary}";
|
||||
testContext?.WriteLine(line);
|
||||
Console.WriteLine(line);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Diagnostics only — never let logging fail a test.
|
||||
}
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||
private struct DEVMODE
|
||||
{
|
||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)]
|
||||
public string DmDeviceName;
|
||||
public short DmSpecVersion;
|
||||
public short DmDriverVersion;
|
||||
public short DmSize;
|
||||
public short DmDriverExtra;
|
||||
public int DmFields;
|
||||
public int DmPositionX;
|
||||
public int DmPositionY;
|
||||
public int DmDisplayOrientation;
|
||||
public int DmDisplayFixedOutput;
|
||||
public short DmColor;
|
||||
public short DmDuplex;
|
||||
public short DmYResolution;
|
||||
public short DmTTOption;
|
||||
public short DmCollate;
|
||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)]
|
||||
public string DmFormName;
|
||||
public short DmLogPixels;
|
||||
public int DmBitsPerPel;
|
||||
public int DmPelsWidth;
|
||||
public int DmPelsHeight;
|
||||
public int DmDisplayFlags;
|
||||
public int DmDisplayFrequency;
|
||||
public int DmICMMethod;
|
||||
public int DmICMIntent;
|
||||
public int DmMediaType;
|
||||
public int DmDitherType;
|
||||
public int DmReserved1;
|
||||
public int DmReserved2;
|
||||
public int DmPanningWidth;
|
||||
public int DmPanningHeight;
|
||||
}
|
||||
}
|
||||
13
src/common/UITestAutomation.Next/Element/Button.cs
Normal file
13
src/common/UITestAutomation.Next/Element/Button.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.PowerToys.UITest.Next;
|
||||
|
||||
public class Button : Element
|
||||
{
|
||||
public Button()
|
||||
{
|
||||
TargetControlType = "Button";
|
||||
}
|
||||
}
|
||||
31
src/common/UITestAutomation.Next/Element/CheckBox.cs
Normal file
31
src/common/UITestAutomation.Next/Element/CheckBox.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.PowerToys.UITest.Next;
|
||||
|
||||
/// <summary>
|
||||
/// WinUI/WPF <c>CheckBox</c> (UIA ControlType <c>CheckBox</c>). State is read via
|
||||
/// <c>winapp ui get-property ToggleState</c> and changed via <c>winapp ui invoke</c>.
|
||||
/// </summary>
|
||||
public class CheckBox : Element
|
||||
{
|
||||
public CheckBox()
|
||||
{
|
||||
TargetControlType = "CheckBox";
|
||||
}
|
||||
|
||||
/// <summary>True when UIA <c>ToggleState</c> is <c>On</c> (<c>Indeterminate</c> reads as not-checked).</summary>
|
||||
public bool IsChecked => string.Equals(GetProperty("ToggleState"), "On", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Flip to <paramref name="value"/> only if currently different.</summary>
|
||||
public CheckBox SetCheck(bool value = true)
|
||||
{
|
||||
if (IsChecked != value)
|
||||
{
|
||||
Click();
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
}
|
||||
52
src/common/UITestAutomation.Next/Element/ComboBox.cs
Normal file
52
src/common/UITestAutomation.Next/Element/ComboBox.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.PowerToys.UITest.Next;
|
||||
|
||||
/// <summary>
|
||||
/// WinUI/WPF <c>ComboBox</c> (UIA ControlType <c>ComboBox</c>). Selection is driven CLI-first:
|
||||
/// <see cref="Select"/> expands via <c>winapp ui invoke</c> then clicks the chosen item, while
|
||||
/// editable combo boxes can be set directly with <see cref="SelectByText"/>
|
||||
/// (<c>winapp ui set-value</c>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The dropdown items live in a popup that the owning process surfaces as a separate window
|
||||
/// (e.g. Settings' <c>PopupHost</c>). Process-scoped sessions (<see cref="Session.FromProcess"/>)
|
||||
/// see those items because every search re-resolves via <c>-a</c>; a window-scoped (<c>-w</c>)
|
||||
/// session may not, in which case prefer <see cref="SelectByText"/>.
|
||||
/// </remarks>
|
||||
public class ComboBox : Element
|
||||
{
|
||||
public ComboBox()
|
||||
{
|
||||
TargetControlType = "ComboBox";
|
||||
}
|
||||
|
||||
/// <summary>Currently selected item text via <c>winapp ui get-value</c> (SelectionPattern fallback).</summary>
|
||||
public string SelectedText => GetValue();
|
||||
|
||||
/// <summary>
|
||||
/// Expand the combo box (CLI <c>invoke</c> toggles ExpandCollapse) and click the item whose
|
||||
/// Name matches <paramref name="itemName"/>.
|
||||
/// </summary>
|
||||
public ComboBox Select(string itemName, int timeoutMS = 5000)
|
||||
{
|
||||
EnsureBound();
|
||||
Click();
|
||||
Thread.Sleep(150);
|
||||
Owner!.Find<Element>(By.Name(itemName), timeoutMS).Click();
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set the combo box value directly via <c>winapp ui set-value</c> (UIA ValuePattern). Works
|
||||
/// for editable combo boxes; for non-editable combos use <see cref="Select"/>.
|
||||
/// </summary>
|
||||
public ComboBox SelectByText(string text)
|
||||
{
|
||||
EnsureBound();
|
||||
WinappCli.InvokeAssertSuccess("ui", "set-value", Selector, text, Owner!.TargetFlag, Owner!.TargetValue);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
17
src/common/UITestAutomation.Next/Element/Custom.cs
Normal file
17
src/common/UITestAutomation.Next/Element/Custom.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.PowerToys.UITest.Next;
|
||||
|
||||
/// <summary>
|
||||
/// Custom control (UIA ControlType <c>Custom</c>) — used by bespoke surfaces like FancyZones
|
||||
/// zones and Workspaces canvases. Inherits drag from <see cref="Element"/>.
|
||||
/// </summary>
|
||||
public class Custom : Element
|
||||
{
|
||||
public Custom()
|
||||
{
|
||||
TargetControlType = "Custom";
|
||||
}
|
||||
}
|
||||
390
src/common/UITestAutomation.Next/Element/Element.cs
Normal file
390
src/common/UITestAutomation.Next/Element/Element.cs
Normal file
@@ -0,0 +1,390 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace Microsoft.PowerToys.UITest.Next;
|
||||
|
||||
/// <summary>Direction for <see cref="Element.Scroll"/> (maps to <c>winapp ui scroll --direction</c>).</summary>
|
||||
public enum ScrollDirection
|
||||
{
|
||||
Up,
|
||||
Down,
|
||||
Left,
|
||||
Right,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reference to a UI element resolved via winappcli. Wraps the resolved <see cref="Selector"/>
|
||||
/// (slug or text query), the owning <see cref="Session"/>, and the metadata captured at lookup
|
||||
/// time (control type, class name, name).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Element instances are <i>stateless on the wire</i> — every property read and every action
|
||||
/// shells out to <c>winapp ui …</c>. The cached <see cref="ControlType"/>, <see cref="ClassName"/>,
|
||||
/// and <see cref="Name"/> are the values seen at <c>Find</c> time; for fresh values, re-find.
|
||||
/// </remarks>
|
||||
public class Element
|
||||
{
|
||||
internal Session? Owner { get; set; }
|
||||
|
||||
/// <summary>The selector winappcli will use to address this element (semantic slug, ID, or text query).</summary>
|
||||
public string Selector { get; internal set; } = string.Empty;
|
||||
|
||||
/// <summary>Cached control type at lookup time (e.g. "Button", "ToggleSwitch").</summary>
|
||||
public string ControlType { get; internal set; } = string.Empty;
|
||||
|
||||
/// <summary>Cached class name at lookup time (e.g. "ToggleSwitch", "TextBlock").</summary>
|
||||
public string ClassName { get; internal set; } = string.Empty;
|
||||
|
||||
/// <summary>Cached Name property at lookup time.</summary>
|
||||
public string Name { get; internal set; } = string.Empty;
|
||||
|
||||
/// <summary>Top-left X (screen pixels) reported by <c>search</c> at lookup time.</summary>
|
||||
public int X { get; internal set; }
|
||||
|
||||
/// <summary>Top-left Y (screen pixels) reported by <c>search</c> at lookup time.</summary>
|
||||
public int Y { get; internal set; }
|
||||
|
||||
/// <summary>Bounding-box width reported by <c>search</c> at lookup time.</summary>
|
||||
public int Width { get; internal set; }
|
||||
|
||||
/// <summary>Bounding-box height reported by <c>search</c> at lookup time.</summary>
|
||||
public int Height { get; internal set; }
|
||||
|
||||
/// <summary>UIA control type that this wrapper subclass expects (e.g. <c>"Button"</c>). Null = match anything.</summary>
|
||||
protected string? TargetControlType { get; set; }
|
||||
|
||||
/// <summary>Optional ClassName filter applied alongside <see cref="TargetControlType"/>.</summary>
|
||||
protected string? TargetClassName { get; set; }
|
||||
|
||||
internal bool MatchesFilter()
|
||||
{
|
||||
if (TargetControlType is not null &&
|
||||
!string.Equals(ControlType, TargetControlType, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (TargetClassName is not null &&
|
||||
!string.Equals(ClassName, TargetClassName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Activate the element. winappcli's <c>invoke</c> tries InvokePattern → TogglePattern →
|
||||
/// SelectionItemPattern → ExpandCollapsePattern in order; <c>rightClick</c> falls back to
|
||||
/// <c>click --right</c> via real mouse input.
|
||||
/// </summary>
|
||||
public virtual void Click(bool rightClick = false, int msPostAction = 200)
|
||||
{
|
||||
EnsureBound();
|
||||
|
||||
if (rightClick)
|
||||
{
|
||||
WinappCli.InvokeAssertSuccess("ui", "click", Selector, Owner!.TargetFlag, Owner!.TargetValue, "--right");
|
||||
}
|
||||
else
|
||||
{
|
||||
WinappCli.InvokeAssertSuccess("ui", "invoke", Selector, Owner!.TargetFlag, Owner!.TargetValue);
|
||||
}
|
||||
|
||||
if (msPostAction > 0)
|
||||
{
|
||||
Thread.Sleep(msPostAction);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mouse-simulation left-click via <c>winapp ui click <slug></c>. Use for elements that
|
||||
/// don't expose an InvokePattern (e.g. TextBlocks, ListItems, column headers), where the
|
||||
/// click is handled by an ancestor's Click handler rather than by the element itself.
|
||||
/// </summary>
|
||||
public void MouseClick(int msPostAction = 200)
|
||||
{
|
||||
EnsureBound();
|
||||
WinappCli.InvokeAssertSuccess("ui", "click", Selector, Owner!.TargetFlag, Owner!.TargetValue);
|
||||
if (msPostAction > 0)
|
||||
{
|
||||
Thread.Sleep(msPostAction);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Double-click via <c>winapp ui click <slug> --double</c> (real mouse simulation). Use
|
||||
/// for controls where a double-click has distinct behavior (list items, headers).
|
||||
/// </summary>
|
||||
public void DoubleClick(int msPostAction = 200)
|
||||
{
|
||||
EnsureBound();
|
||||
WinappCli.InvokeAssertSuccess("ui", "click", Selector, Owner!.TargetFlag, Owner!.TargetValue, "--double");
|
||||
if (msPostAction > 0)
|
||||
{
|
||||
Thread.Sleep(msPostAction);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Scroll this element into the visible area via <c>winapp ui scroll-into-view</c>.</summary>
|
||||
public void ScrollIntoView()
|
||||
{
|
||||
EnsureBound();
|
||||
WinappCli.InvokeAssertSuccess("ui", "scroll-into-view", Selector, Owner!.TargetFlag, Owner!.TargetValue);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scroll the element's nearest scrollable container in <paramref name="direction"/> via
|
||||
/// <c>winapp ui scroll</c>. If this element isn't scrollable, the CLI walks up to the nearest
|
||||
/// scrollable ancestor.
|
||||
/// </summary>
|
||||
public void Scroll(ScrollDirection direction)
|
||||
{
|
||||
EnsureBound();
|
||||
WinappCli.InvokeAssertSuccess(
|
||||
"ui", "scroll", Selector,
|
||||
Owner!.TargetFlag, Owner!.TargetValue,
|
||||
"--direction", direction.ToString().ToLowerInvariant());
|
||||
}
|
||||
|
||||
/// <summary>Jump the element's scrollable container to the top or bottom via <c>winapp ui scroll --to</c>.</summary>
|
||||
public void ScrollToEdge(bool toBottom)
|
||||
{
|
||||
EnsureBound();
|
||||
WinappCli.InvokeAssertSuccess(
|
||||
"ui", "scroll", Selector,
|
||||
Owner!.TargetFlag, Owner!.TargetValue,
|
||||
"--to", toBottom ? "bottom" : "top");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drag this element by a pixel offset using real mouse input (down → stepped move → up).
|
||||
/// Win32-based: winappcli has no drag verb. Uses the element's center from its search bounds.
|
||||
/// </summary>
|
||||
public void Drag(int offsetX, int offsetY)
|
||||
{
|
||||
EnsureBound();
|
||||
var startX = X + (Width / 2);
|
||||
var startY = Y + (Height / 2);
|
||||
MouseHelper.Drag(startX, startY, startX + offsetX, startY + offsetY);
|
||||
}
|
||||
|
||||
/// <summary>Drag this element's center onto <paramref name="target"/>'s center (real mouse input).</summary>
|
||||
public void DragTo(Element target)
|
||||
{
|
||||
EnsureBound();
|
||||
var startX = X + (Width / 2);
|
||||
var startY = Y + (Height / 2);
|
||||
var endX = target.X + (target.Width / 2);
|
||||
var endY = target.Y + (target.Height / 2);
|
||||
MouseHelper.Drag(startX, startY, endX, endY);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hold <paramref name="key"/> down, drag this element's center to absolute screen
|
||||
/// (<paramref name="targetX"/>, <paramref name="targetY"/>), then release the key. Used for
|
||||
/// modifier-drag scenarios (FancyZones merge, tab tear-off).
|
||||
/// </summary>
|
||||
public void KeyDownAndDrag(Key key, int targetX, int targetY)
|
||||
{
|
||||
EnsureBound();
|
||||
var startX = X + (Width / 2);
|
||||
var startY = Y + (Height / 2);
|
||||
KeyboardHelper.PressKey(key);
|
||||
try
|
||||
{
|
||||
MouseHelper.Drag(startX, startY, targetX, targetY);
|
||||
}
|
||||
finally
|
||||
{
|
||||
KeyboardHelper.ReleaseKey(key);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Move keyboard focus to this element.</summary>
|
||||
public void Focus()
|
||||
{
|
||||
EnsureBound();
|
||||
WinappCli.InvokeAssertSuccess("ui", "focus", Selector, Owner!.TargetFlag, Owner!.TargetValue);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read a single UIA property via <c>winapp ui get-property … --json</c>. Returns the raw string
|
||||
/// value as winappcli reports it (e.g. <c>"On"</c>/<c>"Off"</c> for <c>ToggleState</c>).
|
||||
/// </summary>
|
||||
public string GetProperty(string propertyName)
|
||||
{
|
||||
EnsureBound();
|
||||
var r = WinappCli.Invoke("ui", "get-property", Selector, "-p", propertyName, Owner!.TargetFlag, Owner!.TargetValue, "--json");
|
||||
if (string.IsNullOrEmpty(r.StdOut))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(r.StdOut);
|
||||
if (doc.RootElement.TryGetProperty("properties", out var props) &&
|
||||
props.TryGetProperty(propertyName, out var v))
|
||||
{
|
||||
return JsonValueToString(v);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Non-JSON / error output (e.g. property unsupported on this element) — treat as empty.
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// UIA <c>HelpText</c> (from <c>AutomationProperties.HelpText</c>). Used by the Settings UI
|
||||
/// ShortcutControl to surface the current shortcut as readable text on the EditButton
|
||||
/// (e.g. <c>"Win + Shift + C"</c>).
|
||||
/// </summary>
|
||||
public string HelpText => GetProperty("HelpText");
|
||||
|
||||
/// <summary>True when UIA reports the element as enabled (defaults to true when unknown).</summary>
|
||||
public bool IsEnabled => ParseBool(GetProperty("IsEnabled"), defaultValue: true);
|
||||
|
||||
/// <summary>True when UIA reports the element off-screen (defaults to false when unknown).</summary>
|
||||
public bool IsOffscreen => ParseBool(GetProperty("IsOffscreen"), defaultValue: false);
|
||||
|
||||
/// <summary>Convenience inverse of <see cref="IsOffscreen"/> — mirrors the legacy harness's <c>Displayed</c>.</summary>
|
||||
public bool Displayed => !IsOffscreen;
|
||||
|
||||
/// <summary>True when the element is selected (UIA SelectionItemPattern.IsSelected).</summary>
|
||||
public bool Selected => ParseBool(GetProperty("IsSelected"), defaultValue: false);
|
||||
|
||||
/// <summary>The element's UIA AutomationId (empty when it has none).</summary>
|
||||
public string AutomationId => GetProperty("AutomationId");
|
||||
|
||||
/// <summary>
|
||||
/// Read any UIA property by name via <c>winapp ui get-property</c>. Alias of
|
||||
/// <see cref="GetProperty"/> kept for parity with the legacy harness's <c>GetAttribute</c>.
|
||||
/// </summary>
|
||||
public string GetAttribute(string attributeName) => GetProperty(attributeName);
|
||||
|
||||
/// <summary>
|
||||
/// Read the element's value via <c>winapp ui get-value … --json</c>. winappcli walks
|
||||
/// TextPattern → ValuePattern → SelectionPattern → Name to find a value, so this returns
|
||||
/// the rendered text content of TextBlocks (e.g. ColorPicker's <c>ColorTextBlock</c>
|
||||
/// where <c>AutomationProperties.Name</c> overrides the UIA Name with the color's friendly
|
||||
/// name, but the actual <c>Text</c> binding holds the HEX value we want).
|
||||
/// </summary>
|
||||
public string GetValue()
|
||||
{
|
||||
EnsureBound();
|
||||
var root = WinappCli.InvokeJson("ui", "get-value", Selector, Owner!.TargetFlag, Owner!.TargetValue, "--json");
|
||||
if (root.TryGetProperty("text", out var t))
|
||||
{
|
||||
return t.GetString() ?? string.Empty;
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wait for this element to reach <paramref name="expectedValue"/> on <paramref name="propertyName"/>.
|
||||
/// Mirrors <c>winapp ui wait-for --property X --value Y -t T</c>; returns true on success, false on timeout.
|
||||
/// </summary>
|
||||
public bool WaitForProperty(string propertyName, string expectedValue, int timeoutMS = 5000)
|
||||
{
|
||||
EnsureBound();
|
||||
var r = WinappCli.Invoke(
|
||||
"ui", "wait-for", Selector,
|
||||
Owner!.TargetFlag, Owner!.TargetValue,
|
||||
"--property", propertyName,
|
||||
"--value", expectedValue,
|
||||
"-t", timeoutMS.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||
return r.ExitCode == 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wait for this element's value (smart fallback: TextPattern → ValuePattern →
|
||||
/// SelectionPattern → Name) to match <paramref name="expectedValue"/>. When
|
||||
/// <paramref name="contains"/> is true, matches on substring instead of equality
|
||||
/// (<c>winapp ui wait-for … --value … --contains</c>). Returns true on match, false on timeout.
|
||||
/// </summary>
|
||||
public bool WaitForValue(string expectedValue, bool contains = false, int timeoutMS = 5000)
|
||||
{
|
||||
EnsureBound();
|
||||
var args = new List<string>
|
||||
{
|
||||
"ui", "wait-for", Selector,
|
||||
Owner!.TargetFlag, Owner!.TargetValue,
|
||||
"--value", expectedValue,
|
||||
"-t", timeoutMS.ToString(CultureInfo.InvariantCulture),
|
||||
};
|
||||
if (contains)
|
||||
{
|
||||
args.Add("--contains");
|
||||
}
|
||||
|
||||
return WinappCli.Invoke(args.ToArray()).ExitCode == 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wait for any element matching the original selector to disappear from the tree
|
||||
/// (<c>winapp ui wait-for … --gone</c>).
|
||||
/// </summary>
|
||||
public bool WaitForGone(int timeoutMS = 5000)
|
||||
{
|
||||
EnsureBound();
|
||||
var r = WinappCli.Invoke(
|
||||
"ui", "wait-for", Selector,
|
||||
Owner!.TargetFlag, Owner!.TargetValue,
|
||||
"--gone",
|
||||
"-t", timeoutMS.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||
return r.ExitCode == 0;
|
||||
}
|
||||
|
||||
/// <summary>Find a descendant matching <paramref name="by"/>, scoped under this element via its slug.</summary>
|
||||
public T Find<T>(By by, int timeoutMS = 5000)
|
||||
where T : Element, new()
|
||||
{
|
||||
EnsureBound();
|
||||
|
||||
// winappcli scopes a search beneath an element by passing the parent's selector to inspect.
|
||||
// For most cases (within the same window) the global search is fine and faster; if you need
|
||||
// strict scoping under a subtree, use a slug By that prefixes with the parent's slug.
|
||||
return Owner!.FindUnder<T>(by, timeoutMS);
|
||||
}
|
||||
|
||||
public T Find<T>(string name, int timeoutMS = 5000)
|
||||
where T : Element, new() => Find<T>(By.Name(name), timeoutMS);
|
||||
|
||||
protected void EnsureBound()
|
||||
{
|
||||
Assert.IsNotNull(Owner, "Element is not bound to a Session.");
|
||||
Assert.IsFalse(string.IsNullOrEmpty(Selector), "Element has no selector.");
|
||||
}
|
||||
|
||||
/// <summary>Stringify a JSON property value regardless of kind (string / bool / number).</summary>
|
||||
private static string JsonValueToString(JsonElement v) => v.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => v.GetString() ?? string.Empty,
|
||||
JsonValueKind.True => "true",
|
||||
JsonValueKind.False => "false",
|
||||
JsonValueKind.Number => v.GetRawText(),
|
||||
JsonValueKind.Null => string.Empty,
|
||||
_ => v.GetRawText(),
|
||||
};
|
||||
|
||||
/// <summary>Parse a winappcli boolean-ish property string; falls back to <paramref name="defaultValue"/> when empty.</summary>
|
||||
private static bool ParseBool(string raw, bool defaultValue)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
return raw.Trim().ToLowerInvariant() is "true" or "on" or "1" or "yes";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.PowerToys.UITest.Next;
|
||||
|
||||
/// <summary>WinUI NavigationViewItem surfaces as ControlType.ListItem.</summary>
|
||||
public class NavigationViewItem : Element
|
||||
{
|
||||
public NavigationViewItem()
|
||||
{
|
||||
TargetControlType = "ListItem";
|
||||
}
|
||||
}
|
||||
14
src/common/UITestAutomation.Next/Element/Pane.cs
Normal file
14
src/common/UITestAutomation.Next/Element/Pane.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.PowerToys.UITest.Next;
|
||||
|
||||
/// <summary>WinUI/WPF <c>Pane</c> (UIA ControlType <c>Pane</c>). Inherits drag from <see cref="Element"/>.</summary>
|
||||
public class Pane : Element
|
||||
{
|
||||
public Pane()
|
||||
{
|
||||
TargetControlType = "Pane";
|
||||
}
|
||||
}
|
||||
31
src/common/UITestAutomation.Next/Element/RadioButton.cs
Normal file
31
src/common/UITestAutomation.Next/Element/RadioButton.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.PowerToys.UITest.Next;
|
||||
|
||||
/// <summary>
|
||||
/// WinUI/WPF <c>RadioButton</c> (UIA ControlType <c>RadioButton</c>). Selected state is read via
|
||||
/// <c>winapp ui get-property IsSelected</c>; selection is performed via <c>winapp ui invoke</c>.
|
||||
/// </summary>
|
||||
public class RadioButton : Element
|
||||
{
|
||||
public RadioButton()
|
||||
{
|
||||
TargetControlType = "RadioButton";
|
||||
}
|
||||
|
||||
/// <summary>True when this radio button is the selected option (UIA SelectionItemPattern.IsSelected).</summary>
|
||||
public bool IsSelected => Selected;
|
||||
|
||||
/// <summary>Select this radio button if it isn't already selected.</summary>
|
||||
public RadioButton Select()
|
||||
{
|
||||
if (!IsSelected)
|
||||
{
|
||||
Click();
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
}
|
||||
41
src/common/UITestAutomation.Next/Element/Slider.cs
Normal file
41
src/common/UITestAutomation.Next/Element/Slider.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Globalization;
|
||||
|
||||
namespace Microsoft.PowerToys.UITest.Next;
|
||||
|
||||
/// <summary>
|
||||
/// WinUI/WPF <c>Slider</c> (UIA ControlType <c>Slider</c>). Reads and writes the value directly
|
||||
/// through the CLI (<c>winapp ui get-value</c> / <c>set-value</c>, RangeValuePattern) — no
|
||||
/// arrow-key stepping like the legacy harness.
|
||||
/// </summary>
|
||||
public class Slider : Element
|
||||
{
|
||||
public Slider()
|
||||
{
|
||||
TargetControlType = "Slider";
|
||||
}
|
||||
|
||||
/// <summary>Current value via <c>winapp ui get-value</c>. Returns 0 when it can't be parsed.</summary>
|
||||
public double Value
|
||||
{
|
||||
get
|
||||
{
|
||||
var raw = GetValue();
|
||||
return double.TryParse(raw, NumberStyles.Any, CultureInfo.InvariantCulture, out var v) ? v : 0d;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Set the value directly via <c>winapp ui set-value</c> (RangeValuePattern).</summary>
|
||||
public Slider SetValue(double value)
|
||||
{
|
||||
EnsureBound();
|
||||
WinappCli.InvokeAssertSuccess(
|
||||
"ui", "set-value", Selector,
|
||||
value.ToString(CultureInfo.InvariantCulture),
|
||||
Owner!.TargetFlag, Owner!.TargetValue);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
17
src/common/UITestAutomation.Next/Element/Tab.cs
Normal file
17
src/common/UITestAutomation.Next/Element/Tab.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.PowerToys.UITest.Next;
|
||||
|
||||
/// <summary>
|
||||
/// Tab control (UIA ControlType <c>Tab</c>). Inherits drag from <see cref="Element"/> for
|
||||
/// tab-reorder / tear-off scenarios (see <see cref="Element.KeyDownAndDrag"/>).
|
||||
/// </summary>
|
||||
public class Tab : Element
|
||||
{
|
||||
public Tab()
|
||||
{
|
||||
TargetControlType = "Tab";
|
||||
}
|
||||
}
|
||||
20
src/common/UITestAutomation.Next/Element/TextBlock.cs
Normal file
20
src/common/UITestAutomation.Next/Element/TextBlock.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.PowerToys.UITest.Next;
|
||||
|
||||
/// <summary>
|
||||
/// Read-only text element (UIA ControlType <c>Text</c>, e.g. a WinUI <c>TextBlock</c>). The
|
||||
/// rendered text is read via <c>winapp ui get-value</c>, which falls back to the UIA Name.
|
||||
/// </summary>
|
||||
public class TextBlock : Element
|
||||
{
|
||||
public TextBlock()
|
||||
{
|
||||
TargetControlType = "Text";
|
||||
}
|
||||
|
||||
/// <summary>The displayed text via <c>winapp ui get-value</c>.</summary>
|
||||
public string Text => GetValue();
|
||||
}
|
||||
46
src/common/UITestAutomation.Next/Element/TextBox.cs
Normal file
46
src/common/UITestAutomation.Next/Element/TextBox.cs
Normal file
@@ -0,0 +1,46 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.PowerToys.UITest.Next;
|
||||
|
||||
/// <summary>Edit / TextBox control. Drives via <c>winapp ui set-value</c> and <c>get-value</c>.</summary>
|
||||
public class TextBox : Element
|
||||
{
|
||||
public TextBox()
|
||||
{
|
||||
TargetControlType = "Edit";
|
||||
}
|
||||
|
||||
/// <summary>Set the textbox content via winappcli's <c>set-value</c> (UIA ValuePattern).</summary>
|
||||
public TextBox SetText(string value)
|
||||
{
|
||||
EnsureBound();
|
||||
WinappCli.InvokeAssertSuccess("ui", "set-value", Selector, value, Owner!.TargetFlag, Owner!.TargetValue);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>Current text content via <c>winapp ui get-value</c>.</summary>
|
||||
public string Value
|
||||
{
|
||||
get
|
||||
{
|
||||
EnsureBound();
|
||||
var r = WinappCli.Invoke("ui", "get-value", Selector, Owner!.TargetFlag, Owner!.TargetValue, "--json");
|
||||
if (!r.Success)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(r.StdOut);
|
||||
return doc.RootElement.TryGetProperty("text", out var t) ? (t.GetString() ?? string.Empty) : string.Empty;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
17
src/common/UITestAutomation.Next/Element/Thumb.cs
Normal file
17
src/common/UITestAutomation.Next/Element/Thumb.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.PowerToys.UITest.Next;
|
||||
|
||||
/// <summary>
|
||||
/// Resize/move <c>Thumb</c> (UIA ControlType <c>Thumb</c>), e.g. a splitter or slider handle.
|
||||
/// Inherits drag from <see cref="Element"/>.
|
||||
/// </summary>
|
||||
public class Thumb : Element
|
||||
{
|
||||
public Thumb()
|
||||
{
|
||||
TargetControlType = "Thumb";
|
||||
}
|
||||
}
|
||||
32
src/common/UITestAutomation.Next/Element/ToggleSwitch.cs
Normal file
32
src/common/UITestAutomation.Next/Element/ToggleSwitch.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.PowerToys.UITest.Next;
|
||||
|
||||
/// <summary>
|
||||
/// WinUI <c>ToggleSwitch</c> surfaces as <c>ControlType.Button</c> + <c>ClassName="ToggleSwitch"</c>.
|
||||
/// Pinning <see cref="Element.TargetClassName"/> avoids picking up sibling Buttons with the same Name
|
||||
/// (e.g. the module's navigation card on the dashboard).
|
||||
/// </summary>
|
||||
public class ToggleSwitch : Button
|
||||
{
|
||||
public ToggleSwitch()
|
||||
{
|
||||
TargetClassName = "ToggleSwitch";
|
||||
}
|
||||
|
||||
/// <summary>Reads UIA <c>ToggleState</c> via winappcli and compares to <c>"On"</c>.</summary>
|
||||
public bool IsOn => string.Equals(GetProperty("ToggleState"), "On", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Flip to <paramref name="value"/> only if currently different.</summary>
|
||||
public ToggleSwitch Toggle(bool value = true)
|
||||
{
|
||||
if (IsOn != value)
|
||||
{
|
||||
Click();
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
}
|
||||
13
src/common/UITestAutomation.Next/Element/Window.cs
Normal file
13
src/common/UITestAutomation.Next/Element/Window.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.PowerToys.UITest.Next;
|
||||
|
||||
public class Window : Element
|
||||
{
|
||||
public Window()
|
||||
{
|
||||
TargetControlType = "Window";
|
||||
}
|
||||
}
|
||||
71
src/common/UITestAutomation.Next/ElevationHelper.cs
Normal file
71
src/common/UITestAutomation.Next/ElevationHelper.cs
Normal file
@@ -0,0 +1,71 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Microsoft.PowerToys.UITest.Next;
|
||||
|
||||
/// <summary>
|
||||
/// Win32 helpers to determine whether a process is running elevated (admin). winappcli exposes no
|
||||
/// elevation query, so this stays native. Useful for tests that must branch on, or assert, the
|
||||
/// runner's elevation state.
|
||||
/// </summary>
|
||||
public static class ElevationHelper
|
||||
{
|
||||
private const uint TOKEN_QUERY = 0x0008;
|
||||
|
||||
// TOKEN_INFORMATION_CLASS.TokenElevation
|
||||
private const int TokenElevation = 20;
|
||||
|
||||
[DllImport("advapi32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool OpenProcessToken(IntPtr processHandle, uint desiredAccess, out IntPtr tokenHandle);
|
||||
|
||||
[DllImport("advapi32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool GetTokenInformation(IntPtr tokenHandle, int tokenInformationClass, out uint tokenInformation, uint tokenInformationLength, out uint returnLength);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool CloseHandle(IntPtr hObject);
|
||||
|
||||
/// <summary>True when the current test-host process is elevated.</summary>
|
||||
public static bool IsCurrentProcessElevated()
|
||||
{
|
||||
using var p = Process.GetCurrentProcess();
|
||||
return IsHandleElevated(p.Handle);
|
||||
}
|
||||
|
||||
/// <summary>True when process <paramref name="processId"/> is elevated; null if it can't be queried.</summary>
|
||||
public static bool? IsProcessElevated(int processId)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var p = Process.GetProcessById(processId);
|
||||
return IsHandleElevated(p.Handle);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsHandleElevated(IntPtr processHandle)
|
||||
{
|
||||
if (!OpenProcessToken(processHandle, TOKEN_QUERY, out var token))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return GetTokenInformation(token, TokenElevation, out var elevated, sizeof(uint), out _) && elevated != 0;
|
||||
}
|
||||
finally
|
||||
{
|
||||
CloseHandle(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
40
src/common/UITestAutomation.Next/EnvironmentConfig.cs
Normal file
40
src/common/UITestAutomation.Next/EnvironmentConfig.cs
Normal file
@@ -0,0 +1,40 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.PowerToys.UITest.Next;
|
||||
|
||||
/// <summary>
|
||||
/// Centralized access to the environment variables that influence UI-test execution. Mirrors the
|
||||
/// legacy harness's <c>EnvironmentConfig</c> so module tests can branch on pipeline-vs-local and
|
||||
/// installed-build-vs-dev-build the same way.
|
||||
/// </summary>
|
||||
public static class EnvironmentConfig
|
||||
{
|
||||
private static readonly Lazy<bool> InPipeline = new(() =>
|
||||
!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("platform"))
|
||||
|
||||
// TF_BUILD is set to "True" on every Azure DevOps agent and can't be disabled — the
|
||||
// canonical "running in a pipeline" signal. The test job exposes "platform" only as a
|
||||
// template parameter (not an env var), so rely on TF_BUILD to enable CI diagnostics.
|
||||
|| string.Equals(Environment.GetEnvironmentVariable("TF_BUILD"), "true", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private static readonly Lazy<bool> UseInstaller = new(() =>
|
||||
{
|
||||
var raw = Environment.GetEnvironmentVariable("useInstallerForTest")
|
||||
?? Environment.GetEnvironmentVariable("USEINSTALLERFORTEST");
|
||||
return !string.IsNullOrEmpty(raw) && bool.TryParse(raw, out var b) && b;
|
||||
});
|
||||
|
||||
private static readonly Lazy<string?> PlatformValue = new(() =>
|
||||
Environment.GetEnvironmentVariable("platform"));
|
||||
|
||||
/// <summary>True when running in CI/CD (the <c>platform</c> env var is set).</summary>
|
||||
public static bool IsInPipeline => InPipeline.Value;
|
||||
|
||||
/// <summary>True when tests should target the installed PowerToys build (<c>useInstallerForTest</c>).</summary>
|
||||
public static bool UseInstallerForTest => UseInstaller.Value;
|
||||
|
||||
/// <summary>Build platform from the <c>platform</c> env var (e.g. <c>x64</c>, <c>arm64</c>), or null locally.</summary>
|
||||
public static string? Platform => PlatformValue.Value;
|
||||
}
|
||||
162
src/common/UITestAutomation.Next/FRAMEWORK-PARITY-PLAN.md
Normal file
162
src/common/UITestAutomation.Next/FRAMEWORK-PARITY-PLAN.md
Normal file
@@ -0,0 +1,162 @@
|
||||
# UITestAutomation.Next — Parity & Hardening Plan
|
||||
|
||||
Tracks the gaps between the new winappcli-based framework (`UITestAutomation.Next`) and the
|
||||
legacy WinAppDriver/Selenium framework (`UITestAutomation`), plus the ideal end state. **All gaps
|
||||
below are now implemented** — see the per-gap **Done** notes and the Status summary. The detailed
|
||||
sections are kept as the rationale/record.
|
||||
|
||||
> Reference points:
|
||||
> - Legacy base: `src/common/UITestAutomation/UITestBase.cs`
|
||||
> - New base: `src/common/UITestAutomation.Next/UITestBase.cs`
|
||||
> - New launch: `src/common/UITestAutomation.Next/SessionHelper.cs`
|
||||
|
||||
## Status — implemented
|
||||
|
||||
| Gap | Status | Where |
|
||||
|---|---|---|
|
||||
| 1 — Clean-slate hygiene | ✅ Done | `UITestBase.PreTestHygiene()` + `virtual StaleProcessNames`; `WindowControl.TryKillProcessByName` |
|
||||
| 2 — `WindowSize` wired in | ✅ Done | `UITestBase` ctor `size` param + `ApplyWindowSize()` |
|
||||
| 3 — Module-enablement pre-config | ✅ Done | `UITestBase` ctor `enableModules` param → `ConfigureGlobalModuleSettings` before launch |
|
||||
| 4 — Scope teardown / restart | ✅ Done | `SessionHelper.launchedByUs` / `StopIfStarted()` / `Restart()`; `UITestBase.RestartScope(...)` |
|
||||
| 5 — Pipeline diagnostics | ✅ Done (pipeline-gated) | new `ScreenCapture.cs`, `ScreenRecording.cs`, `DisplayHelper.cs`; wired in `UITestBase` |
|
||||
| 6 — Editor-scope launch audit | ✅ Documented | per-scope launch model in `ModuleConfigData.cs` (`PowerToysModule` doc) |
|
||||
|
||||
Framework/test-only change — no product code touched. Harness + both `.Next` consumers
|
||||
(`ColorPicker.UITests`, `Settings.UITests`) build clean (exit 0).
|
||||
|
||||
## Current `.Next` init flow (baseline)
|
||||
|
||||
`TestInit` does exactly:
|
||||
1. Probe `winapp.exe` availability (fail fast with install hint).
|
||||
2. `new SessionHelper(scope)` → `Init()` → launch (runner `--open-settings` for Settings scope) and
|
||||
wait for the first UIA window.
|
||||
|
||||
`TestCleanup` captures a single screenshot on failure, then a no-op `Session.Cleanup()`.
|
||||
|
||||
> Historical (pre-implementation) baseline. Everything below was present in the legacy harness but
|
||||
> **missing or unwired** in `.Next` at the time of writing — now implemented (see Status above).
|
||||
|
||||
---
|
||||
|
||||
## Gap 1 — Clean-slate / window hygiene (HIGH, low risk)
|
||||
|
||||
Legacy `TestInit` starts every test from a known desktop state; `.Next` does none of it.
|
||||
|
||||
| Behavior | Legacy | `.Next` | Plumbing exists? |
|
||||
|---|---|---|---|
|
||||
| Minimize all windows (`Win+M`) | ✅ `KeyboardHelper.SendKeys(Key.Win, Key.M)` | ❌ | ✅ `SendKeys(Key.LWin, Key.M)` |
|
||||
| Kill stale processes (`PowerToys`, `PowerToys.Settings`, `PowerToys.FancyZonesEditor`) | ✅ `CloseOtherApplications()` | ❌ | ✅ `WindowControl.TryKillProcess` |
|
||||
| Dismiss popups (`{ESC}`) before launch | ✅ | ❌ | ✅ `KeyboardHelper` |
|
||||
|
||||
**Plan:** add a `PreTestHygiene()` step at the top of `TestInit` (before `SessionHelper.Init`):
|
||||
minimize-all → ESC → kill known stale processes. Make the stale-process list a `virtual` property so
|
||||
module suites can extend it.
|
||||
|
||||
**Done:** `UITestBase.PreTestHygiene()` runs at the top of `TestInit` — `Win+M` → `Esc` → kill each
|
||||
name in the new `virtual StaleProcessNames` property. Uses the new `WindowControl.TryKillProcessByName`
|
||||
(exact-name match) instead of the Contains-based `TryKillProcess`, so a `PowerToys.*.UITests` test
|
||||
host is never caught by the "PowerToys" entry.
|
||||
|
||||
## Gap 2 — `WindowSize` not wired into the base (HIGH, low risk)
|
||||
|
||||
- Legacy ctor: `UITestBase(PowerToysModule scope, WindowSize size, string[]? commandLineArgs)` and applies
|
||||
`size` during `Session` construction.
|
||||
- `.Next` already has `WindowHelper.SetWindowSize`, the `WindowSize` enum, and `Session.Attach(size)` —
|
||||
but `UITestBase` has no `size` parameter and never applies one. Every `.Next` test runs at the window's
|
||||
default size.
|
||||
- Blocks porting tests that rely on a fixed size, e.g. `src/settings-ui/UITest-Settings/SettingsTests.cs`
|
||||
(`WindowSize.Large`), Hosts/Workspaces (`WindowSize.Medium`), Peek (`Small_Vertical`).
|
||||
|
||||
**Plan:** add `WindowSize size = WindowSize.UnSpecified` to the `UITestBase` ctor; after `Init()` resolves
|
||||
the window, call `WindowHelper.SetWindowSize(hwnd, size)` when `size != UnSpecified`.
|
||||
|
||||
**Done:** `UITestBase` ctor now takes `WindowSize size = UnSpecified` (defaulted). `ApplyWindowSize()`
|
||||
runs after `Init()` (and after every `RestartScope`) and calls
|
||||
`WindowHelper.SetWindowSize(new IntPtr(Session.WindowHandle), size)` when set.
|
||||
|
||||
## Gap 3 — Module-enablement pre-config not wired in (HIGH, low risk)
|
||||
|
||||
- Legacy `StartExe(enableModules)` → `SettingsConfigHelper.ConfigureGlobalModuleSettings(...)` seeds
|
||||
`settings.json` **before** launch, so a test starts from a known module on/off state.
|
||||
- `.Next` ships `SettingsConfigHelper.ConfigureGlobalModuleSettings` but **nothing calls it**. This is the
|
||||
root of the "test assumes module is ON" fragility class.
|
||||
|
||||
**Plan:** add an optional `string[]? enableModules = null` ctor param. When non-null, call
|
||||
`ConfigureGlobalModuleSettings(enableModules)` in `TestInit` **before** launching the runner. Document that
|
||||
passing it gives a deterministic module baseline.
|
||||
|
||||
**Done:** `UITestBase` ctor takes `string[]? enableModules = null`; `TestInit` calls
|
||||
`SettingsConfigHelper.ConfigureGlobalModuleSettings(enableModules)` before `SessionHelper.Init` when it's
|
||||
non-null. The ctor value is also re-applied by `RestartScope()` (unless that call overrides it).
|
||||
|
||||
## Gap 4 — No scope teardown on cleanup (MEDIUM, needs design)
|
||||
|
||||
- Legacy `TestCleanup` → `sessionHelper.Cleanup()` → `ExitScopeExe()` stops what it launched.
|
||||
- `.Next` `Session.Cleanup()` is a no-op and `EnsureRunning`'s "did I launch it" bool is discarded, so the
|
||||
base never stops the process it started. (Individual tests like ColorPicker do their own `finally`.)
|
||||
|
||||
**Design call needed:** per-test teardown (kill scope process) vs. reuse a long-lived runner across a class.
|
||||
Recommended: track the "launched-by-me" bool in `SessionHelper`, expose `StopIfStarted()`, and call it from
|
||||
`TestCleanup` only when the base started the process. Add `RestartScope` convenience equivalent to legacy
|
||||
`RestartScopeExe`.
|
||||
|
||||
**Done:** `SessionHelper` stores `launchedByUs` (set from `EnsureRunning`). `StopIfStarted()` tears down
|
||||
**only** what we launched — kills the scope process and, for the Settings scope, the runner (exact-name
|
||||
match); `TestCleanup` calls it. Instance `SessionHelper.Restart()` does kill → relaunch → rebind.
|
||||
`UITestBase.RestartScope(string[]? enableModules = null)` re-seeds modules (ctor value if null), restarts,
|
||||
reapplies window size, and returns the new `Session` — the `RestartScopeExe` equivalent.
|
||||
|
||||
## Gap 5 — Pipeline diagnostics (MEDIUM/LARGE, CI-only)
|
||||
|
||||
Legacy gates these on `EnvironmentConfig.IsInPipeline`:
|
||||
|
||||
| Behavior | Legacy | `.Next` | Notes |
|
||||
|---|---|---|---|
|
||||
| Normalize resolution to 1920×1080 | ✅ `ChangeDisplayResolution` | ❌ | Port to `MonitorInfo`/native helper |
|
||||
| Monitor info snapshot | ✅ `GetMonitorInfo()` | ⚠️ `MonitorInfo` exists, not called in init | |
|
||||
| Screenshot timer (1s cadence) | ✅ `ScreenCapture.TimerCallback` | ❌ | Needs port |
|
||||
| Screen recording (FFmpeg) | ✅ `ScreenRecording` | ❌ | Needs port |
|
||||
| On failure attach screenshots + recordings + **log files** | ✅ | ⚠️ single screenshot only | Add log-file + recording attach |
|
||||
|
||||
**Plan:** `.Next` `UITestBase` should branch on `EnvironmentConfig.IsInPipeline` and, when true, set up
|
||||
screenshot timer + recording in `TestInit` and attach artifacts in `TestCleanup`. Treat FFmpeg recording as a
|
||||
must have.
|
||||
|
||||
**Done (pipeline-gated on `EnvironmentConfig.IsInPipeline`):** new files `ScreenCapture.cs` (1s screenshot
|
||||
timer), `ScreenRecording.cs` (FFmpeg encode), `DisplayHelper.cs` (`NormalizeResolution(1920,1080)` +
|
||||
`LogMonitors`). `TestInit` normalizes resolution, logs the monitor topology, and starts the timer +
|
||||
recording before launch; `TestCleanup` stops them and, on failure, attaches screenshots + recordings + the
|
||||
PowerToys `*.log` files (`AddLogFilesToTestResults`), cleaning recordings on pass. The local (non-pipeline)
|
||||
path still grabs the single winappcli `--capture-screen` failure shot. *Intentional difference:*
|
||||
`NormalizeResolution` sets `DM_PELSWIDTH | DM_PELSHEIGHT` on the current mode (the documented, reliable
|
||||
request) rather than the legacy's fields-unset call.
|
||||
|
||||
## Gap 6 — Editor scopes still launch the module exe directly (LOW, follow-up)
|
||||
|
||||
After the Settings-scope fix (`PowerToys.exe --open-settings`), editor scopes (Hosts, Workspaces,
|
||||
CommandPalette, FancyZonesEditor, ScreenRuler) still launch their own exe in `SessionHelper.EnsureRunning`.
|
||||
That is correct for editors that are meant to run standalone, but confirm each one against how the runner
|
||||
launches it in production, and document the intended pattern per scope in `ModuleConfigData`.
|
||||
|
||||
**Done:** the launch model is now documented on the `PowerToysModule` enum in `ModuleConfigData.cs` —
|
||||
runner-owned Settings (`--open-settings`), the runner itself, standalone editor scopes (FancyZonesEditor,
|
||||
Hosts, Workspaces, PowerRename, CommandPalette, ScreenRuler), and overlay/background modules (ColorPicker,
|
||||
LightSwitch) that should be driven through the Settings scope rather than launched standalone.
|
||||
|
||||
---
|
||||
|
||||
## Suggested sequencing
|
||||
|
||||
1. ✅ **Phase 1 (quick wins, no API break risk to callers):** Gap 1 hygiene.
|
||||
2. ✅ **Phase 2 (ctor surface):** Gaps 2 + 3 — add `WindowSize` and `enableModules` ctor params (defaulted, so
|
||||
existing `.Next` tests keep compiling). Unblocks porting legacy Settings/Hosts/Workspaces tests.
|
||||
3. ✅ **Phase 3 (lifecycle):** Gap 4 teardown/restart design + implementation.
|
||||
4. ✅ **Phase 4 (CI):** Gap 5 diagnostics, FFmpeg recording.
|
||||
5. ✅ **Phase 5 (cleanup):** Gap 6 per-scope launch audit + docs.
|
||||
|
||||
## Acceptance criteria (per phase)
|
||||
|
||||
- Existing `.Next` tests still compile and pass (defaulted params, no behavior change unless opted in).
|
||||
- New behavior is opt-in or gated (e.g. pipeline-only) so local runs stay fast.
|
||||
- Each ported behavior matches legacy semantics or documents the intentional difference.
|
||||
- No product code changes — framework/test only.
|
||||
204
src/common/UITestAutomation.Next/KeyboardHelper.cs
Normal file
204
src/common/UITestAutomation.Next/KeyboardHelper.cs
Normal file
@@ -0,0 +1,204 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Runtime.InteropServices;
|
||||
using FormsSendKeys = System.Windows.Forms.SendKeys;
|
||||
|
||||
namespace Microsoft.PowerToys.UITest.Next;
|
||||
|
||||
/// <summary>Virtual-key constants used by <see cref="KeyboardHelper"/>.</summary>
|
||||
public enum Key : byte
|
||||
{
|
||||
Ctrl = 0x11,
|
||||
Shift = 0x10,
|
||||
Alt = 0x12,
|
||||
LWin = 0x5B,
|
||||
Tab = 0x09,
|
||||
Esc = 0x1B,
|
||||
Enter = 0x0D,
|
||||
Space = 0x20,
|
||||
Backspace = 0x08,
|
||||
Delete = 0x2E,
|
||||
Insert = 0x2D,
|
||||
Home = 0x24,
|
||||
End = 0x23,
|
||||
PageUp = 0x21,
|
||||
PageDown = 0x22,
|
||||
Left = 0x25,
|
||||
Up = 0x26,
|
||||
Right = 0x27,
|
||||
Down = 0x28,
|
||||
|
||||
A = 0x41,
|
||||
B = 0x42,
|
||||
C = 0x43,
|
||||
D = 0x44,
|
||||
E = 0x45,
|
||||
F = 0x46,
|
||||
G = 0x47,
|
||||
H = 0x48,
|
||||
I = 0x49,
|
||||
J = 0x4A,
|
||||
K = 0x4B,
|
||||
L = 0x4C,
|
||||
M = 0x4D,
|
||||
N = 0x4E,
|
||||
O = 0x4F,
|
||||
P = 0x50,
|
||||
Q = 0x51,
|
||||
R = 0x52,
|
||||
S = 0x53,
|
||||
T = 0x54,
|
||||
U = 0x55,
|
||||
V = 0x56,
|
||||
W = 0x57,
|
||||
X = 0x58,
|
||||
Y = 0x59,
|
||||
Z = 0x5A,
|
||||
|
||||
Num0 = 0x30,
|
||||
Num1 = 0x31,
|
||||
Num2 = 0x32,
|
||||
Num3 = 0x33,
|
||||
Num4 = 0x34,
|
||||
Num5 = 0x35,
|
||||
Num6 = 0x36,
|
||||
Num7 = 0x37,
|
||||
Num8 = 0x38,
|
||||
Num9 = 0x39,
|
||||
|
||||
F1 = 0x70,
|
||||
F2 = 0x71,
|
||||
F3 = 0x72,
|
||||
F4 = 0x73,
|
||||
F5 = 0x74,
|
||||
F6 = 0x75,
|
||||
F7 = 0x76,
|
||||
F8 = 0x77,
|
||||
F9 = 0x78,
|
||||
F10 = 0x79,
|
||||
F11 = 0x7A,
|
||||
F12 = 0x7B,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Global keyboard input. Uses the same hybrid strategy as the legacy harness because pure
|
||||
/// <c>keybd_event</c> injection doesn't reliably trigger <c>RegisterHotKey</c>-registered global
|
||||
/// hotkeys for the PowerToys runner: hold LWIN down via <c>keybd_event</c>, then send the
|
||||
/// remaining chord via <see cref="System.Windows.Forms.SendKeys.SendWait"/> which uses
|
||||
/// SendInput with proper modifier tracking, then release LWIN.
|
||||
/// </summary>
|
||||
public static class KeyboardHelper
|
||||
{
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
#pragma warning disable SA1300 // win32 API name
|
||||
private static extern void keybd_event(byte bVk, byte bScan, uint dwFlags, UIntPtr dwExtraInfo);
|
||||
#pragma warning restore SA1300
|
||||
|
||||
private const uint KEYEVENTF_KEYUP = 0x2;
|
||||
private const uint KEYEVENTF_EXTENDEDKEY = 0x1;
|
||||
private const byte VK_LWIN = 0x5B;
|
||||
|
||||
/// <summary>
|
||||
/// Send a chord of keys. If the chord contains <see cref="Key.LWin"/>, LWIN is held via
|
||||
/// <c>keybd_event</c> while the remaining keys are sent via <see cref="FormsSendKeys.SendWait"/>.
|
||||
/// Otherwise everything goes through SendKeys.SendWait (the modifier-aware Windows path).
|
||||
/// </summary>
|
||||
public static void SendKeys(params Key[] keys)
|
||||
{
|
||||
bool winDown = false;
|
||||
var chord = new System.Text.StringBuilder();
|
||||
|
||||
foreach (var k in keys)
|
||||
{
|
||||
switch (k)
|
||||
{
|
||||
case Key.LWin:
|
||||
keybd_event(VK_LWIN, 0, 0, UIntPtr.Zero);
|
||||
winDown = true;
|
||||
break;
|
||||
case Key.Ctrl: chord.Append('^'); break;
|
||||
case Key.Shift: chord.Append('+'); break;
|
||||
case Key.Alt: chord.Append('%'); break;
|
||||
case Key.Esc: chord.Append("{ESC}"); break;
|
||||
case Key.Enter: chord.Append("{ENTER}"); break;
|
||||
case Key.Tab: chord.Append("{TAB}"); break;
|
||||
case Key.Space: chord.Append(' '); break;
|
||||
case Key.Backspace: chord.Append("{BACKSPACE}"); break;
|
||||
case Key.Delete: chord.Append("{DELETE}"); break;
|
||||
case Key.Insert: chord.Append("{INSERT}"); break;
|
||||
case Key.Home: chord.Append("{HOME}"); break;
|
||||
case Key.End: chord.Append("{END}"); break;
|
||||
case Key.PageUp: chord.Append("{PGUP}"); break;
|
||||
case Key.PageDown: chord.Append("{PGDN}"); break;
|
||||
case Key.Up: chord.Append("{UP}"); break;
|
||||
case Key.Down: chord.Append("{DOWN}"); break;
|
||||
case Key.Left: chord.Append("{LEFT}"); break;
|
||||
case Key.Right: chord.Append("{RIGHT}"); break;
|
||||
case Key.F1: chord.Append("{F1}"); break;
|
||||
case Key.F2: chord.Append("{F2}"); break;
|
||||
case Key.F3: chord.Append("{F3}"); break;
|
||||
case Key.F4: chord.Append("{F4}"); break;
|
||||
case Key.F5: chord.Append("{F5}"); break;
|
||||
case Key.F6: chord.Append("{F6}"); break;
|
||||
case Key.F7: chord.Append("{F7}"); break;
|
||||
case Key.F8: chord.Append("{F8}"); break;
|
||||
case Key.F9: chord.Append("{F9}"); break;
|
||||
case Key.F10: chord.Append("{F10}"); break;
|
||||
case Key.F11: chord.Append("{F11}"); break;
|
||||
case Key.F12: chord.Append("{F12}"); break;
|
||||
default:
|
||||
// Letter / digit keys map to their lowercase character for SendKeys.
|
||||
chord.Append(((char)k).ToString().ToLowerInvariant());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (chord.Length > 0)
|
||||
{
|
||||
FormsSendKeys.SendWait(chord.ToString());
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (winDown)
|
||||
{
|
||||
keybd_event(VK_LWIN, 0, KEYEVENTF_KEYUP, UIntPtr.Zero);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Press (and hold) a key via <c>keybd_event</c>. Pair with <see cref="ReleaseKey"/>.</summary>
|
||||
public static void PressKey(Key key) =>
|
||||
keybd_event((byte)key, 0, IsExtended(key) ? KEYEVENTF_EXTENDEDKEY : 0u, UIntPtr.Zero);
|
||||
|
||||
/// <summary>Release a key previously pressed with <see cref="PressKey"/>.</summary>
|
||||
public static void ReleaseKey(Key key) =>
|
||||
keybd_event((byte)key, 0, KEYEVENTF_KEYUP | (IsExtended(key) ? KEYEVENTF_EXTENDEDKEY : 0u), UIntPtr.Zero);
|
||||
|
||||
/// <summary>Press + release a single key.</summary>
|
||||
public static void SendKey(Key key)
|
||||
{
|
||||
PressKey(key);
|
||||
Thread.Sleep(20);
|
||||
ReleaseKey(key);
|
||||
}
|
||||
|
||||
/// <summary>Press + release each key in order (independent taps, not a held chord).</summary>
|
||||
public static void SendKeySequence(params Key[] keys)
|
||||
{
|
||||
foreach (var k in keys)
|
||||
{
|
||||
SendKey(k);
|
||||
Thread.Sleep(20);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsExtended(Key key) => key is
|
||||
Key.Left or Key.Up or Key.Right or Key.Down or
|
||||
Key.Home or Key.End or Key.PageUp or Key.PageDown or
|
||||
Key.Insert or Key.Delete;
|
||||
}
|
||||
207
src/common/UITestAutomation.Next/ModuleConfigData.cs
Normal file
207
src/common/UITestAutomation.Next/ModuleConfigData.cs
Normal file
@@ -0,0 +1,207 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.PowerToys.UITest.Next;
|
||||
|
||||
/// <summary>
|
||||
/// Modules of PowerToys that a <see cref="UITestBase"/> can target.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Launch model per scope</b> (see <see cref="SessionHelper.EnsureRunning"/>):
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item><description><see cref="PowerToysSettings"/> — runner-owned. Launched via
|
||||
/// <c>PowerToys.exe --open-settings</c> so the runner owns module toggles and activation hotkeys.
|
||||
/// This is the scope to use when a test drives a utility <i>through the Settings UI</i>
|
||||
/// (e.g. <c>ColorPicker.UITests</c>), because a standalone module exe has no runner behind it.</description></item>
|
||||
/// <item><description><see cref="Runner"/> — launches <c>PowerToys.exe</c> directly (the tray/runner host).</description></item>
|
||||
/// <item><description><b>Editor scopes</b> (<see cref="FancyZonesEditor"/>, <see cref="Hosts"/>,
|
||||
/// <see cref="Workspaces"/>, <see cref="PowerRename"/>, <see cref="CommandPalette"/>,
|
||||
/// <see cref="ScreenRuler"/>) — launch their own exe standalone. These are designed to run as
|
||||
/// self-contained editor windows, so binding directly to the editor's window is correct.</description></item>
|
||||
/// <item><description><see cref="ColorPicker"/>, <see cref="LightSwitch"/> — overlay/background
|
||||
/// modules that are <i>not</i> meant to be launched standalone by a test; drive them through the
|
||||
/// <see cref="PowerToysSettings"/> scope (toggle + activation hotkey) instead. The entries exist
|
||||
/// so window/process discovery can still resolve them once the runner spawns them.</description></item>
|
||||
/// </list>
|
||||
public enum PowerToysModule
|
||||
{
|
||||
PowerToysSettings,
|
||||
Runner,
|
||||
ColorPicker,
|
||||
FancyZonesEditor,
|
||||
Hosts,
|
||||
Workspaces,
|
||||
PowerRename,
|
||||
CommandPalette,
|
||||
ScreenRuler,
|
||||
LightSwitch,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves executable paths, process names, and window titles for a <see cref="PowerToysModule"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Path resolution order: an explicit <c>POWERTOYS_INSTALL_DIR</c> override; then, when
|
||||
/// <c>useInstallerForTest</c> is set, the installed build (Program Files / LocalAppData); otherwise
|
||||
/// the build under test — located by walking up from the test assembly to the build-output root that
|
||||
/// holds the exe (locally <c><root>\<plat>\<cfg></c>, in CI the downloaded build artifact) —
|
||||
/// and finally the installed path as a last resort. This lets the same tests run against an installed
|
||||
/// PowerToys or a dev / CI-artifact build without any environment configuration.
|
||||
/// </remarks>
|
||||
internal static class ModulePaths
|
||||
{
|
||||
private sealed record ModuleMeta(string ExeName, string? SubDir, string ProcessName, string WindowTitle);
|
||||
|
||||
private static readonly IReadOnlyDictionary<PowerToysModule, ModuleMeta> Meta =
|
||||
new Dictionary<PowerToysModule, ModuleMeta>
|
||||
{
|
||||
[PowerToysModule.PowerToysSettings] = new("PowerToys.Settings.exe", "WinUI3Apps", "PowerToys.Settings", "PowerToys Settings"),
|
||||
[PowerToysModule.Runner] = new("PowerToys.exe", null, "PowerToys", "PowerToys"),
|
||||
[PowerToysModule.ColorPicker] = new("PowerToys.ColorPickerUI.exe", null, "PowerToys.ColorPickerUI", "PowerToys.ColorPickerUI"),
|
||||
[PowerToysModule.FancyZonesEditor] = new("PowerToys.FancyZonesEditor.exe", null, "PowerToys.FancyZonesEditor", "FancyZones Layout"),
|
||||
[PowerToysModule.Hosts] = new("PowerToys.Hosts.exe", "WinUI3Apps", "PowerToys.Hosts", "Hosts File Editor"),
|
||||
[PowerToysModule.Workspaces] = new("PowerToys.WorkspacesEditor.exe", null, "PowerToys.WorkspacesEditor", "Workspaces Editor"),
|
||||
[PowerToysModule.PowerRename] = new("PowerToys.PowerRename.exe", "WinUI3Apps", "PowerToys.PowerRename", "PowerRename"),
|
||||
[PowerToysModule.CommandPalette] = new("Microsoft.CmdPal.UI.exe", "WinUI3Apps\\CmdPal", "Microsoft.CmdPal.UI", "PowerToys Command Palette"),
|
||||
[PowerToysModule.ScreenRuler] = new("PowerToys.MeasureToolUI.exe", "WinUI3Apps", "PowerToys.MeasureToolUI", "PowerToys.ScreenRuler"),
|
||||
[PowerToysModule.LightSwitch] = new("PowerToys.LightSwitch.exe", "LightSwitchService", "PowerToys.LightSwitch", "PowerToys.LightSwitch"),
|
||||
};
|
||||
|
||||
private static readonly Lazy<string> InstalledRoot = new(ResolveInstalledRoot);
|
||||
private static readonly Lazy<string?> RepoRoot = new(FindRepoRoot);
|
||||
|
||||
public static string ExePathFor(PowerToysModule module)
|
||||
{
|
||||
var meta = Meta[module];
|
||||
|
||||
// 1. Explicit override wins (CI can point at any layout).
|
||||
var overrideDir = Environment.GetEnvironmentVariable("POWERTOYS_INSTALL_DIR");
|
||||
if (!string.IsNullOrEmpty(overrideDir))
|
||||
{
|
||||
var overridePath = Compose(overrideDir, meta);
|
||||
if (File.Exists(overridePath))
|
||||
{
|
||||
return overridePath;
|
||||
}
|
||||
}
|
||||
|
||||
var installed = Compose(InstalledRoot.Value, meta);
|
||||
|
||||
// 2. Installer mode forces the installed layout.
|
||||
if (EnvironmentConfig.UseInstallerForTest)
|
||||
{
|
||||
return installed;
|
||||
}
|
||||
|
||||
// 3. Dev / CI-artifact mode: the build output that holds the exe is an ancestor of the test
|
||||
// assembly. Prefer it so tests drive the build under test, not a stray machine install.
|
||||
if (TryComposeDevBuild(meta, out var dev))
|
||||
{
|
||||
return dev;
|
||||
}
|
||||
|
||||
// 4. Last resort: an installed build if present (returns the installed path either way so a
|
||||
// launch failure names a concrete location).
|
||||
return installed;
|
||||
}
|
||||
|
||||
/// <summary>Process name as winappcli's <c>-a</c> flag accepts it (case-insensitive substring).</summary>
|
||||
public static string ProcessNameFor(PowerToysModule module) => Meta[module].ProcessName;
|
||||
|
||||
/// <summary>Expected window title substring; used to pick the right HWND when a module has several windows.</summary>
|
||||
public static string MainWindowTitleFor(PowerToysModule module) => module switch
|
||||
{
|
||||
// The runner has no user-facing main window title to pin.
|
||||
PowerToysModule.Runner => string.Empty,
|
||||
_ => Meta[module].WindowTitle,
|
||||
};
|
||||
|
||||
private static string Compose(string root, ModuleMeta meta) =>
|
||||
string.IsNullOrEmpty(meta.SubDir)
|
||||
? Path.Combine(root, meta.ExeName)
|
||||
: Path.Combine(root, meta.SubDir, meta.ExeName);
|
||||
|
||||
private static bool TryComposeDevBuild(ModuleMeta meta, out string path)
|
||||
{
|
||||
path = string.Empty;
|
||||
|
||||
// The build-output root that holds PowerToys.exe (and module subdirs like WinUI3Apps) is an
|
||||
// ancestor of the test assembly's bin folder — both locally
|
||||
// (<root>\<plat>\<cfg>\tests\<proj>\<tfm>\) and in CI (the downloaded build artifact, which
|
||||
// can nest <plat>\<cfg> more than once). Walk up and return the first ancestor that actually
|
||||
// contains the requested exe. Mirrors the legacy harness's "<assembly>\..\..\..\<exe>"
|
||||
// convention without hard-coding the depth.
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir is not null)
|
||||
{
|
||||
var candidate = Compose(dir.FullName, meta);
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
path = candidate;
|
||||
return true;
|
||||
}
|
||||
|
||||
dir = dir.Parent;
|
||||
}
|
||||
|
||||
// Fallback: repo root + conventional <plat>\<cfg> output, for the rare case the assembly
|
||||
// isn't located under the build tree.
|
||||
var root = RepoRoot.Value;
|
||||
if (!string.IsNullOrEmpty(root))
|
||||
{
|
||||
foreach (var platform in new[] { "x64", "ARM64" })
|
||||
{
|
||||
foreach (var config in new[] { "Debug", "Release" })
|
||||
{
|
||||
var candidate = Compose(Path.Combine(root, platform, config), meta);
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
path = candidate;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string ResolveInstalledRoot()
|
||||
{
|
||||
string[] candidates =
|
||||
{
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "PowerToys"),
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), "PowerToys"),
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "PowerToys"),
|
||||
};
|
||||
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
if (File.Exists(Path.Combine(candidate, "PowerToys.exe")))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
private static string? FindRepoRoot()
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir is not null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(dir.FullName, "PowerToys.slnx")))
|
||||
{
|
||||
return dir.FullName;
|
||||
}
|
||||
|
||||
dir = dir.Parent;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
104
src/common/UITestAutomation.Next/MonitorInfo.cs
Normal file
104
src/common/UITestAutomation.Next/MonitorInfo.cs
Normal file
@@ -0,0 +1,104 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Microsoft.PowerToys.UITest.Next;
|
||||
|
||||
/// <summary>
|
||||
/// Multi-monitor enumeration via Win32 (<c>EnumDisplayMonitors</c> / <c>GetMonitorInfo</c>).
|
||||
/// winappcli exposes no display topology, so this stays native — useful for multi-monitor
|
||||
/// utilities (FancyZones, Mouse Utilities, Mouse Without Borders).
|
||||
/// </summary>
|
||||
public static class MonitorInfo
|
||||
{
|
||||
/// <summary>One physical display, in virtual-screen pixel coordinates.</summary>
|
||||
public sealed record Monitor(
|
||||
string DeviceName,
|
||||
int Left,
|
||||
int Top,
|
||||
int Right,
|
||||
int Bottom,
|
||||
int WorkLeft,
|
||||
int WorkTop,
|
||||
int WorkRight,
|
||||
int WorkBottom,
|
||||
bool IsPrimary)
|
||||
{
|
||||
/// <summary>Full monitor width in pixels.</summary>
|
||||
public int Width => Right - Left;
|
||||
|
||||
/// <summary>Full monitor height in pixels.</summary>
|
||||
public int Height => Bottom - Top;
|
||||
}
|
||||
|
||||
private const uint MONITORINFOF_PRIMARY = 0x1;
|
||||
|
||||
private delegate bool MonitorEnumProc(IntPtr hMonitor, IntPtr hdc, ref RECT lprcMonitor, IntPtr dwData);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool EnumDisplayMonitors(IntPtr hdc, IntPtr lprcClip, MonitorEnumProc lpfnEnum, IntPtr dwData);
|
||||
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool GetMonitorInfo(IntPtr hMonitor, ref MONITORINFOEX lpmi);
|
||||
|
||||
/// <summary>All connected displays, in enumeration order.</summary>
|
||||
public static IReadOnlyList<Monitor> GetAll()
|
||||
{
|
||||
var list = new List<Monitor>();
|
||||
|
||||
EnumDisplayMonitors(IntPtr.Zero, IntPtr.Zero, EnumCallback, IntPtr.Zero);
|
||||
return list;
|
||||
|
||||
bool EnumCallback(IntPtr hMonitor, IntPtr hdc, ref RECT lprcMonitor, IntPtr dwData)
|
||||
{
|
||||
var mi = new MONITORINFOEX { CbSize = Marshal.SizeOf<MONITORINFOEX>() };
|
||||
if (GetMonitorInfo(hMonitor, ref mi))
|
||||
{
|
||||
list.Add(new Monitor(
|
||||
mi.SzDevice,
|
||||
mi.RcMonitor.Left,
|
||||
mi.RcMonitor.Top,
|
||||
mi.RcMonitor.Right,
|
||||
mi.RcMonitor.Bottom,
|
||||
mi.RcWork.Left,
|
||||
mi.RcWork.Top,
|
||||
mi.RcWork.Right,
|
||||
mi.RcWork.Bottom,
|
||||
(mi.DwFlags & MONITORINFOF_PRIMARY) != 0));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The primary display, or null if none reported.</summary>
|
||||
public static Monitor? GetPrimary() => GetAll().FirstOrDefault(m => m.IsPrimary);
|
||||
|
||||
/// <summary>Number of connected displays.</summary>
|
||||
public static int Count => GetAll().Count;
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct RECT
|
||||
{
|
||||
public int Left;
|
||||
public int Top;
|
||||
public int Right;
|
||||
public int Bottom;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||
private struct MONITORINFOEX
|
||||
{
|
||||
public int CbSize;
|
||||
public RECT RcMonitor;
|
||||
public RECT RcWork;
|
||||
public uint DwFlags;
|
||||
|
||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)]
|
||||
public string SzDevice;
|
||||
}
|
||||
}
|
||||
191
src/common/UITestAutomation.Next/MouseHelper.cs
Normal file
191
src/common/UITestAutomation.Next/MouseHelper.cs
Normal file
@@ -0,0 +1,191 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Microsoft.PowerToys.UITest.Next;
|
||||
|
||||
/// <summary>
|
||||
/// Global mouse input via Win32 <c>SetCursorPos</c> and <c>SendInput</c>. Required for
|
||||
/// scenarios like clicking inside the ColorPicker overlay, which is a transparent window that
|
||||
/// can't be targeted via UIA / <c>winapp ui click</c>.
|
||||
/// </summary>
|
||||
public static class MouseHelper
|
||||
{
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct POINT
|
||||
{
|
||||
public int X;
|
||||
public int Y;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct MOUSEINPUT
|
||||
{
|
||||
public int Dx;
|
||||
public int Dy;
|
||||
public uint MouseData;
|
||||
public uint DwFlags;
|
||||
public uint Time;
|
||||
public UIntPtr DwExtraInfo;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct INPUT
|
||||
{
|
||||
public uint Type;
|
||||
public MOUSEINPUT Mi;
|
||||
}
|
||||
|
||||
private const uint INPUT_MOUSE = 0;
|
||||
|
||||
private const uint MOUSEEVENTF_LEFTDOWN = 0x02;
|
||||
private const uint MOUSEEVENTF_LEFTUP = 0x04;
|
||||
private const uint MOUSEEVENTF_RIGHTDOWN = 0x08;
|
||||
private const uint MOUSEEVENTF_RIGHTUP = 0x10;
|
||||
private const uint MOUSEEVENTF_MIDDLEDOWN = 0x20;
|
||||
private const uint MOUSEEVENTF_MIDDLEUP = 0x40;
|
||||
private const uint MOUSEEVENTF_WHEEL = 0x0800;
|
||||
|
||||
private const int ClickDelayMs = 100;
|
||||
private const int WheelTick = 120;
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern bool SetCursorPos(int x, int y);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool GetCursorPos(out POINT lpPoint);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern uint SendInput(uint nInputs, INPUT[] pInputs, int cbSize);
|
||||
|
||||
/// <summary>Move the OS cursor to absolute screen coordinates.</summary>
|
||||
public static void MoveTo(int x, int y) => SetCursorPos(x, y);
|
||||
|
||||
/// <summary>Current cursor position in screen pixels.</summary>
|
||||
public static (int X, int Y) GetMousePosition()
|
||||
{
|
||||
GetCursorPos(out var p);
|
||||
return (p.X, p.Y);
|
||||
}
|
||||
|
||||
/// <summary>Press the left mouse button down at the current position.</summary>
|
||||
public static void LeftDown() => SendMouseInput(MOUSEEVENTF_LEFTDOWN);
|
||||
|
||||
/// <summary>Release the left mouse button.</summary>
|
||||
public static void LeftUp() => SendMouseInput(MOUSEEVENTF_LEFTUP);
|
||||
|
||||
/// <summary>Press the right mouse button down at the current position.</summary>
|
||||
public static void RightDown() => SendMouseInput(MOUSEEVENTF_RIGHTDOWN);
|
||||
|
||||
/// <summary>Release the right mouse button.</summary>
|
||||
public static void RightUp() => SendMouseInput(MOUSEEVENTF_RIGHTUP);
|
||||
|
||||
/// <summary>Press the middle mouse button down at the current position.</summary>
|
||||
public static void MiddleDown() => SendMouseInput(MOUSEEVENTF_MIDDLEDOWN);
|
||||
|
||||
/// <summary>Release the middle mouse button.</summary>
|
||||
public static void MiddleUp() => SendMouseInput(MOUSEEVENTF_MIDDLEUP);
|
||||
|
||||
/// <summary>Press + release left mouse button at the current cursor position.</summary>
|
||||
public static void LeftClick()
|
||||
{
|
||||
LeftDown();
|
||||
Thread.Sleep(ClickDelayMs);
|
||||
LeftUp();
|
||||
}
|
||||
|
||||
/// <summary>Move cursor to (x,y) and left-click.</summary>
|
||||
public static void LeftClickAt(int x, int y)
|
||||
{
|
||||
MoveTo(x, y);
|
||||
Thread.Sleep(40);
|
||||
LeftClick();
|
||||
}
|
||||
|
||||
/// <summary>Press + release right mouse button at the current cursor position.</summary>
|
||||
public static void RightClick()
|
||||
{
|
||||
RightDown();
|
||||
Thread.Sleep(ClickDelayMs);
|
||||
RightUp();
|
||||
}
|
||||
|
||||
/// <summary>Press + release middle mouse button at the current cursor position.</summary>
|
||||
public static void MiddleClick()
|
||||
{
|
||||
MiddleDown();
|
||||
Thread.Sleep(ClickDelayMs);
|
||||
MiddleUp();
|
||||
}
|
||||
|
||||
/// <summary>Left double-click at the current cursor position.</summary>
|
||||
public static void DoubleClick()
|
||||
{
|
||||
LeftClick();
|
||||
Thread.Sleep(ClickDelayMs);
|
||||
LeftClick();
|
||||
}
|
||||
|
||||
/// <summary>Scroll the wheel by a raw amount (positive = up, negative = down; one tick = 120).</summary>
|
||||
public static void ScrollWheel(int amount) => SendMouseInput(MOUSEEVENTF_WHEEL, amount);
|
||||
|
||||
/// <summary>Scroll the wheel up by one tick.</summary>
|
||||
public static void ScrollUp() => ScrollWheel(WheelTick);
|
||||
|
||||
/// <summary>Scroll the wheel down by one tick.</summary>
|
||||
public static void ScrollDown() => ScrollWheel(-WheelTick);
|
||||
|
||||
/// <summary>
|
||||
/// Drag from one absolute screen point to another with real mouse input: move → left-down →
|
||||
/// stepped move → left-up. winappcli has no drag verb, so this stays Win32. Coordinates are
|
||||
/// physical screen pixels (matching <c>winapp ui search</c> bounds).
|
||||
/// </summary>
|
||||
public static void Drag(int fromX, int fromY, int toX, int toY)
|
||||
{
|
||||
MoveTo(fromX, fromY);
|
||||
Thread.Sleep(100);
|
||||
|
||||
LeftDown();
|
||||
Thread.Sleep(100);
|
||||
|
||||
MoveTo(toX, toY);
|
||||
Thread.Sleep(200);
|
||||
|
||||
LeftUp();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Injects a single mouse event into the system input queue via <see cref="SendInput"/>.
|
||||
/// Button and wheel events fire at the current cursor position, so <paramref name="data"/>
|
||||
/// only carries the wheel delta for <c>MOUSEEVENTF_WHEEL</c>.
|
||||
/// </summary>
|
||||
private static void SendMouseInput(uint flags, int data = 0)
|
||||
{
|
||||
var inputs = new INPUT[]
|
||||
{
|
||||
new INPUT
|
||||
{
|
||||
Type = INPUT_MOUSE,
|
||||
Mi = new MOUSEINPUT
|
||||
{
|
||||
Dx = 0,
|
||||
Dy = 0,
|
||||
MouseData = (uint)data,
|
||||
DwFlags = flags,
|
||||
Time = 0,
|
||||
DwExtraInfo = UIntPtr.Zero,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
var sent = SendInput((uint)inputs.Length, inputs, Marshal.SizeOf<INPUT>());
|
||||
if (sent != inputs.Length)
|
||||
{
|
||||
throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
}
|
||||
}
|
||||
}
|
||||
237
src/common/UITestAutomation.Next/ScreenRecording.cs
Normal file
237
src/common/UITestAutomation.Next/ScreenRecording.cs
Normal file
@@ -0,0 +1,237 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Globalization;
|
||||
using ScreenRecorderLib;
|
||||
|
||||
namespace Microsoft.PowerToys.UITest.Next;
|
||||
|
||||
/// <summary>
|
||||
/// Records the desktop to an MP4 during a UI test using ScreenRecorderLib, which encodes in realtime
|
||||
/// with native Microsoft Media Foundation (H.264). Used only by the pipeline path of
|
||||
/// <see cref="UITestBase"/>. Unlike the old GDI + FFmpeg path there is nothing to probe for on PATH;
|
||||
/// any runtime problem is surfaced through <c>OnRecordingFailed</c> and handled gracefully so the
|
||||
/// failing test is never blocked — screenshots still cover the failure.
|
||||
/// </summary>
|
||||
internal sealed class ScreenRecording : IDisposable
|
||||
{
|
||||
// Deliberately light capture settings: on CI runners without a GPU, ScreenRecorderLib falls back
|
||||
// to software H.264, and a full 1080p/30fps realtime encode competes with the test for CPU. 15 fps
|
||||
// at 720p (~4x less pixel throughput than 1080p/30) is still plenty to see what a UI test did.
|
||||
// Tune these down further (e.g. 10 fps / 960x540) if a runner is still CPU-starved.
|
||||
private const int TargetFps = 15;
|
||||
private const int OutputWidth = 1280;
|
||||
private const int OutputHeight = 720;
|
||||
|
||||
/// <summary>Upper bound on how long to wait for Media Foundation to flush the MP4 after <c>Stop()</c>.</summary>
|
||||
private static readonly TimeSpan FinalizeTimeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
private readonly string outputDirectory;
|
||||
private readonly string outputFilePath;
|
||||
private readonly object syncRoot = new();
|
||||
|
||||
private Recorder? recorder;
|
||||
private TaskCompletionSource<bool>? recordingFinished;
|
||||
private bool isRecording;
|
||||
|
||||
public ScreenRecording(string outputDirectory)
|
||||
{
|
||||
this.outputDirectory = outputDirectory;
|
||||
var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss", CultureInfo.InvariantCulture);
|
||||
outputFilePath = Path.Combine(outputDirectory, $"recording_{timestamp}.mp4");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when recording can be attempted. ScreenRecorderLib ships its native encoder in-package,
|
||||
/// so there is nothing to locate at runtime; a missing prerequisite (e.g. Media Foundation on a
|
||||
/// Windows N/Server SKU) is reported through <c>OnRecordingFailed</c> rather than here.
|
||||
/// </summary>
|
||||
public bool IsAvailable => true;
|
||||
|
||||
/// <summary>Path the encoded MP4 will be written to.</summary>
|
||||
public string OutputFilePath => outputFilePath;
|
||||
|
||||
/// <summary>Directory containing the recording output.</summary>
|
||||
public string OutputDirectory => outputDirectory;
|
||||
|
||||
/// <summary>Start recording the main display. Best-effort and non-blocking.</summary>
|
||||
public Task StartRecordingAsync()
|
||||
{
|
||||
lock (syncRoot)
|
||||
{
|
||||
if (isRecording)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(outputDirectory);
|
||||
|
||||
var options = new RecorderOptions
|
||||
{
|
||||
OutputOptions = new OutputOptions
|
||||
{
|
||||
RecorderMode = RecorderMode.Video,
|
||||
|
||||
// Downscale from the test desktop (normalized to 1080p) to 720p. Both are 16:9 so
|
||||
// Uniform is a clean scale with no letterboxing, and encoding ~2.25x fewer pixels
|
||||
// is the single biggest CPU saving when the runner falls back to software H.264.
|
||||
OutputFrameSize = new ScreenSize(OutputWidth, OutputHeight),
|
||||
Stretch = StretchMode.Uniform,
|
||||
},
|
||||
VideoEncoderOptions = new VideoEncoderOptions
|
||||
{
|
||||
Framerate = TargetFps,
|
||||
|
||||
// Baseline is the cheapest H.264 profile to encode (no B-frames/CABAC); the
|
||||
// library's own docs note lesser profiles "use less resources" — ideal for a
|
||||
// throwaway diagnostic clip on a runner that falls back to software encoding.
|
||||
Encoder = new H264VideoEncoder { EncoderProfile = H264Profile.Baseline },
|
||||
|
||||
// Force a constant frame rate. Without this, ScreenRecorderLib only sends a
|
||||
// frame to the encoder when the screen *changes* (variable frame rate), while
|
||||
// the MP4 still advertises TargetFps. Long static stretches (e.g. waiting for a
|
||||
// module to launch) then collapse to a handful of frames and bursts of activity
|
||||
// get packed together, so playback drifts out of sync with wall-clock time — the
|
||||
// video runs fast/offset and the tail of the test looks cut off. Duplicating the
|
||||
// previous frame keeps the timeline 1:1 with real time; H.264 compresses the
|
||||
// repeated frames to almost nothing, so the file stays small. At 15 fps the extra
|
||||
// duplicated idle frames are nearly free to encode.
|
||||
IsFixedFramerate = true,
|
||||
|
||||
// Prefer encode speed over quality — this is a throwaway diagnostic clip, and a
|
||||
// lower-latency encode leaves more CPU for the test itself on shared CI agents.
|
||||
IsLowLatencyEnabled = true,
|
||||
},
|
||||
|
||||
// UI tests don't need audio, and capturing it can fail on headless CI agents.
|
||||
AudioOptions = new AudioOptions
|
||||
{
|
||||
IsAudioEnabled = false,
|
||||
},
|
||||
|
||||
// Keep the cursor visible so a failed run shows what was being clicked.
|
||||
MouseOptions = new MouseOptions
|
||||
{
|
||||
IsMousePointerEnabled = true,
|
||||
},
|
||||
};
|
||||
|
||||
recordingFinished = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
recorder = Recorder.CreateRecorder(options);
|
||||
recorder.OnRecordingComplete += OnRecordingComplete;
|
||||
recorder.OnRecordingFailed += OnRecordingFailed;
|
||||
recorder.Record(outputFilePath);
|
||||
|
||||
isRecording = true;
|
||||
Console.WriteLine($"Started screen recording at {TargetFps} FPS to {outputFilePath}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Failed to start recording: {ex.Message}");
|
||||
DisposeRecorder();
|
||||
isRecording = false;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Stop recording and wait for Media Foundation to finalize the MP4. Best-effort.</summary>
|
||||
public async Task StopRecordingAsync()
|
||||
{
|
||||
Recorder? activeRecorder;
|
||||
TaskCompletionSource<bool>? finished;
|
||||
|
||||
lock (syncRoot)
|
||||
{
|
||||
if (!isRecording || recorder is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
activeRecorder = recorder;
|
||||
finished = recordingFinished;
|
||||
isRecording = false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
activeRecorder.Stop();
|
||||
|
||||
if (finished is not null)
|
||||
{
|
||||
// Bound the wait so a stuck encoder never hangs test teardown.
|
||||
var completed = await Task.WhenAny(finished.Task, Task.Delay(FinalizeTimeout)).ConfigureAwait(false);
|
||||
if (completed != finished.Task)
|
||||
{
|
||||
Console.WriteLine("Timed out waiting for the recording to finalize.");
|
||||
}
|
||||
}
|
||||
|
||||
if (File.Exists(outputFilePath))
|
||||
{
|
||||
var fileInfo = new FileInfo(outputFilePath);
|
||||
Console.WriteLine($"Video created: {outputFilePath} ({fileInfo.Length / 1024.0 / 1024.0:F1} MB)");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error stopping recording: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
DisposeRecorder();
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (isRecording)
|
||||
{
|
||||
StopRecordingAsync().GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
DisposeRecorder();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private void OnRecordingComplete(object? sender, RecordingCompleteEventArgs e)
|
||||
{
|
||||
recordingFinished?.TrySetResult(true);
|
||||
}
|
||||
|
||||
private void OnRecordingFailed(object? sender, RecordingFailedEventArgs e)
|
||||
{
|
||||
Console.WriteLine($"Screen recording failed: {e.Error}");
|
||||
recordingFinished?.TrySetResult(false);
|
||||
}
|
||||
|
||||
private void DisposeRecorder()
|
||||
{
|
||||
lock (syncRoot)
|
||||
{
|
||||
if (recorder is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
recorder.OnRecordingComplete -= OnRecordingComplete;
|
||||
recorder.OnRecordingFailed -= OnRecordingFailed;
|
||||
|
||||
try
|
||||
{
|
||||
recorder.Dispose();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Failed to dispose recorder: {ex.Message}");
|
||||
}
|
||||
|
||||
recorder = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
421
src/common/UITestAutomation.Next/Session.cs
Normal file
421
src/common/UITestAutomation.Next/Session.cs
Normal file
@@ -0,0 +1,421 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace Microsoft.PowerToys.UITest.Next;
|
||||
|
||||
/// <summary>
|
||||
/// A test session bound to either a specific window (HWND) or a whole process (name or PID).
|
||||
/// All <see cref="Find{T}"/>/<see cref="FindAll{T}"/> calls route to <c>winapp ui search</c>
|
||||
/// scoped by <see cref="TargetFlag"/>/<see cref="TargetValue"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Two scopes are supported:
|
||||
/// <list type="bullet">
|
||||
/// <item><description><c>Window</c> (<c>-w <hwnd></c>) — the default. Use when the
|
||||
/// process owns multiple windows and the test needs to pin one (e.g. ColorPickerUI's
|
||||
/// overlay vs editor; Settings vs PopupHost).</description></item>
|
||||
/// <item><description><c>Process</c> (<c>-a <name|pid></c>) — simpler when the target
|
||||
/// process owns exactly one user-facing window. Built via <see cref="FromProcess"/>. Matches
|
||||
/// the pattern in <see href="https://github.com/microsoft/PowerToys/pull/48414"/>.</description></item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
public sealed class Session
|
||||
{
|
||||
public enum TargetScope
|
||||
{
|
||||
/// <summary>Scope all CLI calls to a specific HWND via <c>-w</c>.</summary>
|
||||
Window,
|
||||
|
||||
/// <summary>Scope all CLI calls to a process (name substring or PID) via <c>-a</c>.</summary>
|
||||
Process,
|
||||
}
|
||||
|
||||
/// <summary>Decimal HWND of the target window, or 0 when bound by <see cref="TargetScope.Process"/>.</summary>
|
||||
public long WindowHandle { get; }
|
||||
|
||||
/// <summary>String form of <see cref="WindowHandle"/> for passing to winappcli's <c>-w</c> flag.</summary>
|
||||
public string WindowHandleArg { get; }
|
||||
|
||||
/// <summary>The scope these calls run against (window or process).</summary>
|
||||
public TargetScope Scope { get; }
|
||||
|
||||
/// <summary>winappcli flag for the active scope (<c>-w</c> or <c>-a</c>).</summary>
|
||||
public string TargetFlag { get; }
|
||||
|
||||
/// <summary>Value to pass after <see cref="TargetFlag"/> — the decimal HWND or the process name/PID.</summary>
|
||||
public string TargetValue { get; }
|
||||
|
||||
public string WindowTitle { get; }
|
||||
|
||||
public int ProcessId { get; }
|
||||
|
||||
public string ProcessName { get; }
|
||||
|
||||
public PowerToysModule InitScope { get; }
|
||||
|
||||
/// <summary>True when the target process is elevated; null when unknown (no PID captured).</summary>
|
||||
public bool? IsElevated => ProcessId > 0 ? ElevationHelper.IsProcessElevated(ProcessId) : null;
|
||||
|
||||
internal Session(PowerToysModule scope, long hwnd, string title, int pid, string processName)
|
||||
{
|
||||
InitScope = scope;
|
||||
WindowHandle = hwnd;
|
||||
WindowHandleArg = hwnd.ToString(CultureInfo.InvariantCulture);
|
||||
Scope = TargetScope.Window;
|
||||
TargetFlag = "-w";
|
||||
TargetValue = WindowHandleArg;
|
||||
WindowTitle = title;
|
||||
ProcessId = pid;
|
||||
ProcessName = processName;
|
||||
}
|
||||
|
||||
private Session(PowerToysModule scope, string appNameOrPid, int pid, string processName, string title)
|
||||
{
|
||||
InitScope = scope;
|
||||
WindowHandle = 0;
|
||||
WindowHandleArg = "0";
|
||||
Scope = TargetScope.Process;
|
||||
TargetFlag = "-a";
|
||||
TargetValue = appNameOrPid;
|
||||
WindowTitle = title;
|
||||
ProcessId = pid;
|
||||
ProcessName = processName;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build a session scoped to a whole process via <c>winapp ... -a <app></c>. Cheaper than
|
||||
/// resolving a HWND and ideal for the single-window-per-process case (e.g. Settings smoke
|
||||
/// tests). The first matching window's PID/name/title are captured for reporting only — all
|
||||
/// subsequent CLI calls re-resolve via <c>-a</c>, so window-replacement during the test
|
||||
/// (re-navigation, page swap) is handled transparently.
|
||||
/// </summary>
|
||||
/// <param name="appNameOrPid">Process name substring (e.g. <c>"PowerToys.Settings"</c>) or PID as a string.</param>
|
||||
/// <param name="attributeAs">Module label used for diagnostics only.</param>
|
||||
/// <param name="timeoutMS">How long to wait for the process to expose at least one UIA window.</param>
|
||||
public static Session FromProcess(
|
||||
string appNameOrPid,
|
||||
PowerToysModule attributeAs = PowerToysModule.Runner,
|
||||
int timeoutMS = 10_000)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + TimeSpan.FromMilliseconds(timeoutMS);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
var windows = WindowsFinder.ListByApp(appNameOrPid);
|
||||
if (windows.Count > 0)
|
||||
{
|
||||
var w = windows[0];
|
||||
return new Session(attributeAs, appNameOrPid, w.ProcessId, w.ProcessName, w.Title);
|
||||
}
|
||||
|
||||
Thread.Sleep(250);
|
||||
}
|
||||
|
||||
Assert.Fail(
|
||||
$"FromProcess('{appNameOrPid}'): no UIA-visible window appeared within {timeoutMS}ms. " +
|
||||
$"Is the app running? Run 'winapp ui list-windows -a {appNameOrPid}' to confirm.");
|
||||
return null!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attach to a running module's first window (window-scoped, so it carries a HWND) and
|
||||
/// optionally resize it to a preset <see cref="WindowSize"/>. Useful when a test needs a
|
||||
/// deterministic window size or wants to drive an already-running module.
|
||||
/// </summary>
|
||||
public static Session Attach(PowerToysModule module, WindowSize size = WindowSize.UnSpecified, int timeoutMS = 10_000)
|
||||
{
|
||||
var processName = ModulePaths.ProcessNameFor(module);
|
||||
var deadline = DateTime.UtcNow + TimeSpan.FromMilliseconds(timeoutMS);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
var windows = WindowsFinder.ListByApp(processName);
|
||||
if (windows.Count > 0)
|
||||
{
|
||||
var w = windows[0];
|
||||
if (size != WindowSize.UnSpecified && w.Hwnd != 0)
|
||||
{
|
||||
WindowHelper.SetWindowSize(new IntPtr(w.Hwnd), size);
|
||||
Thread.Sleep(200);
|
||||
}
|
||||
|
||||
return new Session(module, w.Hwnd, w.Title, w.ProcessId, w.ProcessName);
|
||||
}
|
||||
|
||||
Thread.Sleep(250);
|
||||
}
|
||||
|
||||
Assert.Fail($"Attach: no UIA-visible window for module {module} ('{processName}') within {timeoutMS}ms.");
|
||||
return null!;
|
||||
}
|
||||
|
||||
public T Find<T>(By by, int timeoutMS = 5000)
|
||||
where T : Element, new() => FindUnder<T>(by, timeoutMS);
|
||||
|
||||
public T Find<T>(string name, int timeoutMS = 5000)
|
||||
where T : Element, new() => FindUnder<T>(By.Name(name), timeoutMS);
|
||||
|
||||
public Element Find(By by, int timeoutMS = 5000) => FindUnder<Element>(by, timeoutMS);
|
||||
|
||||
public Element Find(string name, int timeoutMS = 5000) => FindUnder<Element>(By.Name(name), timeoutMS);
|
||||
|
||||
public bool Has<T>(By by, int timeoutMS = 1000)
|
||||
where T : Element, new() => FindAll<T>(by, timeoutMS).Count >= 1;
|
||||
|
||||
public bool Has(By by, int timeoutMS = 1000) => Has<Element>(by, timeoutMS);
|
||||
|
||||
public bool Has(string name, int timeoutMS = 1000) => Has<Element>(By.Name(name), timeoutMS);
|
||||
|
||||
public bool HasOne<T>(By by, int timeoutMS = 1000)
|
||||
where T : Element, new() => FindAll<T>(by, timeoutMS).Count == 1;
|
||||
|
||||
/// <summary>
|
||||
/// All elements matching <paramref name="by"/> on this session's window, optionally polling
|
||||
/// for up to <paramref name="timeoutMS"/> if none are present initially.
|
||||
/// </summary>
|
||||
public ReadOnlyCollection<T> FindAll<T>(By by, int timeoutMS = 5000)
|
||||
where T : Element, new()
|
||||
{
|
||||
var deadline = DateTime.UtcNow + TimeSpan.FromMilliseconds(timeoutMS);
|
||||
|
||||
while (true)
|
||||
{
|
||||
var matches = ExecuteSearch(by);
|
||||
var typed = new List<T>(matches.Count);
|
||||
foreach (var m in matches)
|
||||
{
|
||||
var e = new T
|
||||
{
|
||||
Owner = this,
|
||||
Selector = m.Selector,
|
||||
ControlType = m.ControlType,
|
||||
ClassName = m.ClassName,
|
||||
Name = m.Name,
|
||||
X = m.X,
|
||||
Y = m.Y,
|
||||
Width = m.Width,
|
||||
Height = m.Height,
|
||||
};
|
||||
if (e.MatchesFilter())
|
||||
{
|
||||
typed.Add(e);
|
||||
}
|
||||
}
|
||||
|
||||
if (typed.Count > 0 || DateTime.UtcNow >= deadline)
|
||||
{
|
||||
return new ReadOnlyCollection<T>(typed);
|
||||
}
|
||||
|
||||
Thread.Sleep(200);
|
||||
}
|
||||
}
|
||||
|
||||
internal T FindUnder<T>(By by, int timeoutMS)
|
||||
where T : Element, new()
|
||||
{
|
||||
var collection = FindAll<T>(by, timeoutMS);
|
||||
Assert.IsTrue(collection.Count > 0, $"UI-Element({typeof(T).Name}) not found using selector: {by}");
|
||||
return collection[0];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generic polling helper, equivalent to winappcli's <c>wait-for --value</c> but evaluated in C#
|
||||
/// so the predicate can read multiple properties / compose conditions.
|
||||
/// </summary>
|
||||
public bool WaitFor(Func<bool> condition, int timeoutMS = 5000, int pollIntervalMS = 100)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + TimeSpan.FromMilliseconds(timeoutMS);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (condition())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Treat property reads on stale elements as "not yet true".
|
||||
}
|
||||
|
||||
Thread.Sleep(pollIntervalMS);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wait for an element matching <paramref name="by"/> to appear in the tree via
|
||||
/// <c>winapp ui wait-for</c>. Returns true if it appeared within <paramref name="timeoutMS"/>.
|
||||
/// </summary>
|
||||
public bool WaitForElement(By by, int timeoutMS = 5000)
|
||||
{
|
||||
var r = WinappCli.Invoke(
|
||||
"ui", "wait-for", by.Value,
|
||||
TargetFlag, TargetValue,
|
||||
"-t", timeoutMS.ToString(CultureInfo.InvariantCulture));
|
||||
return r.ExitCode == 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Capture a PNG of the session's target via <c>winapp ui screenshot</c>. Pass an
|
||||
/// <paramref name="element"/> to crop to that element's bounds, or set
|
||||
/// <paramref name="captureScreen"/> to grab from the screen (includes popups / overlays /
|
||||
/// flyouts that <c>PrintWindow</c> misses).
|
||||
/// </summary>
|
||||
public string Screenshot(string outputPath, Element? element = null, bool captureScreen = false)
|
||||
{
|
||||
WinappCli.InvokeAssertSuccess(BuildScreenshotArgs(outputPath, element, captureScreen));
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
/// <summary>Non-asserting screenshot for cleanup / failure-artifact paths. Returns false on error.</summary>
|
||||
public bool TryScreenshot(string outputPath, Element? element = null, bool captureScreen = false)
|
||||
{
|
||||
try
|
||||
{
|
||||
return WinappCli.Invoke(BuildScreenshotArgs(outputPath, element, captureScreen)).Success;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private string[] BuildScreenshotArgs(string outputPath, Element? element, bool captureScreen)
|
||||
{
|
||||
var args = new List<string> { "ui", "screenshot" };
|
||||
if (element is not null && !string.IsNullOrEmpty(element.Selector))
|
||||
{
|
||||
args.Add(element.Selector);
|
||||
}
|
||||
|
||||
args.Add(TargetFlag);
|
||||
args.Add(TargetValue);
|
||||
args.Add("-o");
|
||||
args.Add(outputPath);
|
||||
if (captureScreen)
|
||||
{
|
||||
args.Add("--capture-screen");
|
||||
}
|
||||
|
||||
return args.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dump the UIA tree for this session's target via <c>winapp ui inspect --json</c>.
|
||||
/// Returned shape: <c>{ "windows": [{ "elements": [{ "type", "name", "value", "children": [...] }] }] }</c>.
|
||||
/// </summary>
|
||||
/// <param name="depth">Tree depth (ignored by winappcli when <paramref name="interactive"/> is set).</param>
|
||||
/// <param name="interactive">Only invokable elements (auto-depth), as a flat list.</param>
|
||||
/// <param name="hideDisabled">Omit disabled elements.</param>
|
||||
/// <param name="hideOffscreen">Omit off-screen elements.</param>
|
||||
public JsonElement Inspect(int depth = 6, bool interactive = false, bool hideDisabled = false, bool hideOffscreen = false)
|
||||
{
|
||||
var args = new List<string>
|
||||
{
|
||||
"ui", "inspect",
|
||||
TargetFlag, TargetValue,
|
||||
"--json",
|
||||
"-d", depth.ToString(CultureInfo.InvariantCulture),
|
||||
};
|
||||
if (interactive)
|
||||
{
|
||||
args.Add("--interactive");
|
||||
}
|
||||
|
||||
if (hideDisabled)
|
||||
{
|
||||
args.Add("--hide-disabled");
|
||||
}
|
||||
|
||||
if (hideOffscreen)
|
||||
{
|
||||
args.Add("--hide-offscreen");
|
||||
}
|
||||
|
||||
return WinappCli.InvokeJson(args.ToArray());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Walk the ancestor chain from <paramref name="element"/> up to the root via
|
||||
/// <c>winapp ui inspect --ancestors</c>.
|
||||
/// </summary>
|
||||
public JsonElement InspectAncestors(Element element) =>
|
||||
WinappCli.InvokeJson("ui", "inspect", "--ancestors", element.Selector, TargetFlag, TargetValue, "--json");
|
||||
|
||||
/// <summary>The element that currently has keyboard focus, via <c>winapp ui get-focused --json</c>.</summary>
|
||||
public JsonElement GetFocused() => WinappCli.InvokeJson("ui", "get-focused", TargetFlag, TargetValue, "--json");
|
||||
|
||||
/// <summary>
|
||||
/// Convenience reader for the focused element's Name (empty if none / unknown). Useful for
|
||||
/// keyboard-navigation assertions.
|
||||
/// </summary>
|
||||
public string GetFocusedName()
|
||||
{
|
||||
try
|
||||
{
|
||||
var root = GetFocused();
|
||||
foreach (var prop in new[] { "name", "Name" })
|
||||
{
|
||||
if (root.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return v.GetString() ?? string.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best effort — no focused element or unexpected envelope.
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Connection / target info for diagnostics via <c>winapp ui status --json</c>.</summary>
|
||||
public JsonElement Status() => WinappCli.InvokeJson("ui", "status", TargetFlag, TargetValue, "--json");
|
||||
|
||||
/// <summary>Send keystrokes via Win32 <c>keybd_event</c>. Required for global PowerToys hotkeys.</summary>
|
||||
public void SendKeys(params Key[] keys) => KeyboardHelper.SendKeys(keys);
|
||||
|
||||
public void Cleanup()
|
||||
{
|
||||
// Stateless — nothing to release on the wire.
|
||||
}
|
||||
|
||||
private List<SearchHit> ExecuteSearch(By by)
|
||||
{
|
||||
// winappcli accepts the selector text directly as the first positional argument.
|
||||
var root = WinappCli.InvokeJson("ui", "search", by.Value, TargetFlag, TargetValue, "--json");
|
||||
|
||||
var result = new List<SearchHit>();
|
||||
if (root.TryGetProperty("matches", out var arr) && arr.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var m in arr.EnumerateArray())
|
||||
{
|
||||
result.Add(new SearchHit(
|
||||
Selector: m.TryGetProperty("selector", out var s) ? (s.GetString() ?? string.Empty) : string.Empty,
|
||||
Name: m.TryGetProperty("name", out var n) ? (n.GetString() ?? string.Empty) : string.Empty,
|
||||
ControlType: m.TryGetProperty("type", out var t) ? (t.GetString() ?? string.Empty) : string.Empty,
|
||||
ClassName: m.TryGetProperty("className", out var c) ? (c.GetString() ?? string.Empty) : string.Empty,
|
||||
X: ReadInt(m, "x"),
|
||||
Y: ReadInt(m, "y"),
|
||||
Width: ReadInt(m, "width"),
|
||||
Height: ReadInt(m, "height")));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
static int ReadInt(JsonElement el, string name) =>
|
||||
el.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Number ? v.GetInt32() : 0;
|
||||
}
|
||||
|
||||
private sealed record SearchHit(string Selector, string Name, string ControlType, string ClassName, int X, int Y, int Width, int Height);
|
||||
}
|
||||
384
src/common/UITestAutomation.Next/SessionHelper.cs
Normal file
384
src/common/UITestAutomation.Next/SessionHelper.cs
Normal file
@@ -0,0 +1,384 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace Microsoft.PowerToys.UITest.Next;
|
||||
|
||||
/// <summary>
|
||||
/// Owns process launch + window resolution for a <see cref="PowerToysModule"/>. Equivalent to
|
||||
/// the old <c>SessionHelper</c> but the engine is winappcli — no WinAppDriver, no Appium.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Two consumption shapes:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>Per-test (HWND-scoped): construct + call <see cref="Init"/>. <see cref="UITestBase"/>
|
||||
/// does this in <c>[TestInitialize]</c>.</description></item>
|
||||
/// <item><description>Class-scoped or process-scoped: the static helpers (<see cref="EnsureRunning"/>,
|
||||
/// <see cref="IsRunning"/>, <see cref="GetProcessName"/>) let a smoke-test <c>[ClassInitialize]</c>
|
||||
/// reuse the launch+wait flow without taking on a HWND binding.</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class SessionHelper
|
||||
{
|
||||
// Generous window-appearance budget. On a cold/busy CI agent the runner spends tens of seconds
|
||||
// enabling every module and the Settings WinUI process cold-starts before its window appears.
|
||||
// When the whole test job runs elevated (required so the legacy WinAppDriver harness can bind
|
||||
// :4723) the runner's startup is slower still — ~100s to the first Settings window observed on a
|
||||
// slow platform — so the budget is 150s. We wait patiently (and only re-issue the launch when
|
||||
// nothing is alive) rather than kill-and-relaunch on a short deadline, which only resets a
|
||||
// slow-but-healthy startup and never converges.
|
||||
private static readonly TimeSpan LaunchTimeout = TimeSpan.FromSeconds(150);
|
||||
|
||||
private readonly PowerToysModule scope;
|
||||
|
||||
// True when this helper's Init/Restart actually launched the scope (vs. attaching to an
|
||||
// already-running instance). StopIfStarted only tears down what we created.
|
||||
private bool launchedByUs;
|
||||
|
||||
public SessionHelper(PowerToysModule scope)
|
||||
{
|
||||
this.scope = scope;
|
||||
}
|
||||
|
||||
public Session Init()
|
||||
{
|
||||
launchedByUs = EnsureRunning(scope, LaunchTimeout);
|
||||
return ResolveMainWindowOrFail();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Force a clean restart of this helper's scope: kill the scope process (plus the runner for the
|
||||
/// Settings scope), relaunch, and rebind to the fresh window. Marks the session launched-by-us so
|
||||
/// <see cref="StopIfStarted"/> tears it down. Mirrors the net effect of the legacy <c>RestartScopeExe</c>.
|
||||
/// </summary>
|
||||
public Session Restart()
|
||||
{
|
||||
StopScope();
|
||||
EnsureRunning(scope, LaunchTimeout);
|
||||
launchedByUs = true;
|
||||
return ResolveMainWindowOrFail();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stop the process(es) this helper launched. No-op when the target was already running at
|
||||
/// <see cref="Init"/> time — we never kill state the test didn't create. Mirrors the legacy
|
||||
/// <c>ExitScopeExe</c>, scoped to "only what we started".
|
||||
/// </summary>
|
||||
public void StopIfStarted()
|
||||
{
|
||||
if (!launchedByUs)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
StopScope();
|
||||
launchedByUs = false;
|
||||
}
|
||||
|
||||
private Session ResolveMainWindowOrFail()
|
||||
{
|
||||
var window = WaitForMainWindow(scope, LaunchTimeout);
|
||||
Assert.IsNotNull(window, $"Main window for {scope} did not appear via winappcli within {LaunchTimeout.TotalSeconds:0}s");
|
||||
return window!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Kill the scope's process and, for the Settings scope, the runner that owns it (the runner's
|
||||
/// exit also stops the modules it spawned). Uses exact-name matching so unrelated processes that
|
||||
/// merely contain "PowerToys" in their name (e.g. the test host) are left alone. Waits briefly
|
||||
/// for the scope process to disappear.
|
||||
/// </summary>
|
||||
private void StopScope() => KillScopeProcessesAndWait(scope);
|
||||
|
||||
/// <summary>Process name as winappcli's <c>-a</c> flag (and <see cref="Process.GetProcessesByName(string)"/>) accept it.</summary>
|
||||
public static string GetProcessName(PowerToysModule scope) => ModulePaths.ProcessNameFor(scope);
|
||||
|
||||
/// <summary>Returns <c>true</c> if at least one process matching <paramref name="scope"/> is running.</summary>
|
||||
public static bool IsRunning(PowerToysModule scope) =>
|
||||
Process.GetProcessesByName(GetProcessName(scope)).Length > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Ensure the runner-owned environment for <paramref name="scope"/> is up and has presented a
|
||||
/// UIA-visible window. Returns <c>false</c> when the target was already running (nothing
|
||||
/// launched), <c>true</c> when a launch was needed — callers track this so cleanup only kills
|
||||
/// what the test itself started.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The PowerToys <b>runner</b> (<c>PowerToys.exe</c>) is the single entry point. It installs the
|
||||
/// centralized keyboard hook and owns every module's start/stop lifecycle. Tests therefore
|
||||
/// launch the runner and drive modules through the Settings UI — they never launch a module's
|
||||
/// UI exe (e.g. <c>PowerToys.ColorPickerUI.exe</c>) standalone. A standalone module process has
|
||||
/// no runner behind it, so its activation hotkey never fires and toggling it in Settings does
|
||||
/// nothing. For the <see cref="PowerToysModule.PowerToysSettings"/> scope we launch
|
||||
/// <c>PowerToys.exe --open-settings</c>: the runner starts (or, being single-instance, the
|
||||
/// already-running one is signalled) and presents the Settings window.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <c>UseShellExecute = true</c> is intentional: with <c>UseShellExecute = false</c> the
|
||||
/// spawned process inherits this test-host's stdin/stdout/stderr handles, and the
|
||||
/// Microsoft.Testing.Platform / MSTest runner won't declare the test run complete until
|
||||
/// those pipes drain — which never happens until the target exits. Going through
|
||||
/// ShellExecute gives the child its own console and detaches the handles.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// PowerToys processes with single-instance gates (runner, Settings, ColorPicker) often hand
|
||||
/// off to an existing instance and let the launcher PID exit with code 0 immediately. The
|
||||
/// launcher PID is therefore intentionally discarded; readiness is judged purely by whether a
|
||||
/// UIA window owned by the target process becomes visible.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static bool EnsureRunning(PowerToysModule scope, TimeSpan timeout)
|
||||
{
|
||||
// Whether or not the scope process already exists, the test needs its WINDOW. EnsureWindow
|
||||
// waits patiently and (idempotently) re-issues the launch as needed; it only kills/relaunches
|
||||
// a genuinely-dead fresh launch, never a slow-but-healthy or class-shared (reused) window.
|
||||
var alreadyRunning = IsRunning(scope);
|
||||
EnsureWindow(scope, timeout, alreadyRunning);
|
||||
return !alreadyRunning;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wait for a UIA-visible window from <paramref name="scope"/> to appear, launching / re-issuing
|
||||
/// the launch as needed. The Settings scope is launched through the runner
|
||||
/// (<c>PowerToys.exe --open-settings</c>); see <see cref="EnsureRunning"/> remarks.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// On a busy/cold CI agent the runner spends tens of seconds enabling every module before the
|
||||
/// Settings window appears (~30-50s observed). A "kill + relaunch every 20s" loop kept resetting
|
||||
/// that slow-but-healthy startup so it never converged (the "runner: 1, Settings: 2, no window"
|
||||
/// failures). Instead this waits a single generous <paramref name="timeout"/> and only acts when
|
||||
/// the window is still missing after a grace period: it re-issues the launch — idempotent, since
|
||||
/// the runner is single-instance, so <c>--open-settings</c> just (re)shows Settings — and
|
||||
/// additionally clears the single-instance mutex first only for a fresh launch that has gone
|
||||
/// completely dead (nothing running), i.e. the handoff-to-a-now-exited-instance race. A
|
||||
/// class-shared (reused) window is never killed.
|
||||
/// </remarks>
|
||||
private static void EnsureWindow(PowerToysModule scope, TimeSpan timeout, bool alreadyRunning)
|
||||
{
|
||||
var processName = GetProcessName(scope);
|
||||
var runnerName = GetProcessName(PowerToysModule.Runner);
|
||||
var nudgeInterval = TimeSpan.FromSeconds(25);
|
||||
|
||||
if (!alreadyRunning)
|
||||
{
|
||||
// Release the single-instance mutex any stale/half-launched instance still holds (pre-test
|
||||
// hygiene kills without waiting), then launch.
|
||||
KillScopeProcessesAndWait(scope);
|
||||
LaunchScope(scope);
|
||||
}
|
||||
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
var lastLaunch = DateTime.UtcNow;
|
||||
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (WindowsFinder.ListByApp(processName).Count > 0)
|
||||
{
|
||||
// Give XAML a moment to populate the visual tree.
|
||||
Thread.Sleep(750);
|
||||
return;
|
||||
}
|
||||
|
||||
if (DateTime.UtcNow - lastLaunch > nudgeInterval)
|
||||
{
|
||||
// Re-issue the launch ONLY when nothing is alive to present the window — the genuine
|
||||
// "launcher handed off to an instance that then exited" race. If the runner is still
|
||||
// alive it already owns the queued --open-settings request and, on a slow agent, may
|
||||
// need tens of seconds to enable every module before it spawns Settings. Re-launching
|
||||
// there is NOT free: each extra --open-settings queues another request that the runner
|
||||
// honours with a SEPARATE Settings.exe (the "Settings: 3" pile-up seen in CI), and the
|
||||
// competing single-instance processes plus the launch contention push the window past
|
||||
// the deadline. So when anything is alive, keep waiting instead of piling on.
|
||||
var alive = IsRunning(scope) || Process.GetProcessesByName(runnerName).Length > 0;
|
||||
if (!alive)
|
||||
{
|
||||
if (!alreadyRunning)
|
||||
{
|
||||
KillScopeProcessesAndWait(scope);
|
||||
}
|
||||
|
||||
LaunchScope(scope);
|
||||
lastLaunch = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
Thread.Sleep(500);
|
||||
}
|
||||
|
||||
Assert.Fail(
|
||||
$"No UIA-visible window from process '{processName}' appeared within {timeout.TotalSeconds:0}s. " +
|
||||
$"Live processes — runner '{runnerName}': {Process.GetProcessesByName(runnerName).Length}, " +
|
||||
$"'{processName}': {Process.GetProcessesByName(processName).Length}.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Issue a single detached launch for <paramref name="scope"/>: the runner with
|
||||
/// <c>--open-settings</c> for the Settings scope (the runner owns the Settings UI — see
|
||||
/// <see cref="EnsureRunning"/> remarks), or the scope's own exe otherwise.
|
||||
/// </summary>
|
||||
private static void LaunchScope(PowerToysModule scope)
|
||||
{
|
||||
if (scope == PowerToysModule.PowerToysSettings)
|
||||
{
|
||||
LaunchViaShell(ModulePaths.ExePathFor(PowerToysModule.Runner), "--open-settings");
|
||||
}
|
||||
else
|
||||
{
|
||||
LaunchViaShell(ModulePaths.ExePathFor(scope), null);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Kill the scope's process — plus the runner for the Settings scope, which owns the
|
||||
/// single-instance mutex that <c>--open-settings</c> hands off to — and wait for them to exit.
|
||||
/// The wait is the point: relaunching while a just-killed runner still holds its mutex hands the
|
||||
/// new launch off to the dying instance, which never presents a window.
|
||||
/// </summary>
|
||||
private static void KillScopeProcessesAndWait(PowerToysModule scope)
|
||||
{
|
||||
var names = scope == PowerToysModule.PowerToysSettings
|
||||
? new[] { GetProcessName(PowerToysModule.PowerToysSettings), GetProcessName(PowerToysModule.Runner) }
|
||||
: new[] { GetProcessName(scope) };
|
||||
|
||||
foreach (var name in names)
|
||||
{
|
||||
WindowControl.TryKillProcessByName(name);
|
||||
}
|
||||
|
||||
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
|
||||
while (DateTime.UtcNow < deadline && names.Any(n => Process.GetProcessesByName(n).Length > 0))
|
||||
{
|
||||
Thread.Sleep(150);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Launch <paramref name="exe"/> detached via ShellExecute (see <see cref="EnsureRunning"/>
|
||||
/// remarks for why <c>UseShellExecute = true</c> is required). The launcher PID is discarded;
|
||||
/// readiness is judged by window presence, not the process handle.
|
||||
/// </summary>
|
||||
private static void LaunchViaShell(string exe, string? arguments)
|
||||
{
|
||||
Assert.IsTrue(File.Exists(exe), $"Executable not found: {exe}");
|
||||
|
||||
try
|
||||
{
|
||||
using (Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = exe,
|
||||
Arguments = arguments ?? string.Empty,
|
||||
WorkingDirectory = Path.GetDirectoryName(exe)!,
|
||||
UseShellExecute = true,
|
||||
}) ?? throw new InvalidOperationException($"Process.Start returned null for {exe}"))
|
||||
{
|
||||
// Fire and forget — see EnsureRunning <remarks>.
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Assert.Fail($"Failed to launch '{exe} {arguments}': {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Force a clean restart of the module: kill any running instance, wait for it to exit, then
|
||||
/// launch a fresh one and wait for its window. Returns true once a window is visible.
|
||||
/// </summary>
|
||||
public static bool RestartScope(PowerToysModule scope, TimeSpan timeout)
|
||||
{
|
||||
var processName = GetProcessName(scope);
|
||||
WindowControl.TryKillProcess(processName);
|
||||
|
||||
var killDeadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
|
||||
while (DateTime.UtcNow < killDeadline && Process.GetProcessesByName(processName).Length > 0)
|
||||
{
|
||||
Thread.Sleep(150);
|
||||
}
|
||||
|
||||
return EnsureRunning(scope, timeout);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Poll <c>winapp ui list-windows --json</c> until a window matching the target module appears.
|
||||
/// Returns a <see cref="Session"/> bound to its HWND.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When the same process owns multiple windows (Settings exe also owns the <c>PopupHost</c>
|
||||
/// overlay), we strictly prefer a window whose title contains the expected title. Process-name
|
||||
/// match is only used as a fallback for modules that don't pin a specific title.
|
||||
/// </remarks>
|
||||
private static Session? WaitForMainWindow(PowerToysModule scope, TimeSpan timeout)
|
||||
{
|
||||
var processName = ModulePaths.ProcessNameFor(scope);
|
||||
var expectedTitle = ModulePaths.MainWindowTitleFor(scope);
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
var r = WinappCli.Invoke("ui", "list-windows", "--json");
|
||||
if (r.Success && !string.IsNullOrEmpty(r.StdOut))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(r.StdOut);
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
Session? processFallback = null;
|
||||
|
||||
foreach (var w in doc.RootElement.EnumerateArray())
|
||||
{
|
||||
var pn = w.TryGetProperty("processName", out var pnEl) ? (pnEl.GetString() ?? string.Empty) : string.Empty;
|
||||
var title = w.TryGetProperty("title", out var tEl) ? (tEl.GetString() ?? string.Empty) : string.Empty;
|
||||
var hwnd = w.TryGetProperty("hwnd", out var hwndEl) && hwndEl.ValueKind == JsonValueKind.Number ? hwndEl.GetInt64() : 0L;
|
||||
var pid = w.TryGetProperty("processId", out var pidEl) && pidEl.ValueKind == JsonValueKind.Number ? pidEl.GetInt32() : 0;
|
||||
|
||||
if (hwnd == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Strict title match wins immediately — disambiguates from sibling
|
||||
// windows owned by the same process (e.g. Settings + PopupHost).
|
||||
if (!string.IsNullOrEmpty(expectedTitle) &&
|
||||
title.Contains(expectedTitle, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new Session(scope, hwnd, title, pid, pn);
|
||||
}
|
||||
|
||||
// Track the first process-name match as a fallback for modules where no
|
||||
// expected title is configured.
|
||||
if (processFallback is null &&
|
||||
!string.IsNullOrEmpty(processName) &&
|
||||
pn.Contains(processName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
processFallback = new Session(scope, hwnd, title, pid, pn);
|
||||
}
|
||||
}
|
||||
|
||||
// No title match yet — only fall back to the process match if the module
|
||||
// really has no expected title configured.
|
||||
if (string.IsNullOrEmpty(expectedTitle) && processFallback is not null)
|
||||
{
|
||||
return processFallback;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Bad JSON during startup — keep polling.
|
||||
}
|
||||
}
|
||||
|
||||
Thread.Sleep(250);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
138
src/common/UITestAutomation.Next/SettingsConfigHelper.cs
Normal file
138
src/common/UITestAutomation.Next/SettingsConfigHelper.cs
Normal file
@@ -0,0 +1,138 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace Microsoft.PowerToys.UITest.Next;
|
||||
|
||||
/// <summary>
|
||||
/// Lightweight helpers for preparing PowerToys settings JSON before a test launches a module.
|
||||
/// Reads/writes the JSON files directly with System.Text.Json so the harness keeps zero product
|
||||
/// dependencies — unlike the legacy helper, which referenced <c>Settings.UI.Library</c>.
|
||||
/// </summary>
|
||||
public static class SettingsConfigHelper
|
||||
{
|
||||
private static readonly JsonSerializerOptions Indented = new() { WriteIndented = true };
|
||||
|
||||
/// <summary>Root of the per-user PowerToys settings: <c>%LocalAppData%\Microsoft\PowerToys</c>.</summary>
|
||||
public static string PowerToysSettingsRoot => Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"Microsoft",
|
||||
"PowerToys");
|
||||
|
||||
private static string GlobalSettingsPath => Path.Combine(PowerToysSettingsRoot, "settings.json");
|
||||
|
||||
/// <summary>
|
||||
/// Enable exactly the named modules in the global <c>settings.json</c> and disable every other
|
||||
/// module already listed. Module names are the keys under <c>"enabled"</c> (e.g. "FancyZones",
|
||||
/// "ColorPicker", "Peek"). Creates the file and keys when missing.
|
||||
/// </summary>
|
||||
public static void ConfigureGlobalModuleSettings(params string[]? modulesToEnable)
|
||||
{
|
||||
modulesToEnable ??= Array.Empty<string>();
|
||||
Directory.CreateDirectory(PowerToysSettingsRoot);
|
||||
|
||||
var root = File.Exists(GlobalSettingsPath)
|
||||
? (JsonNode.Parse(File.ReadAllText(GlobalSettingsPath)) as JsonObject) ?? new JsonObject()
|
||||
: new JsonObject();
|
||||
|
||||
if (root["enabled"] is not JsonObject enabled)
|
||||
{
|
||||
enabled = new JsonObject();
|
||||
root["enabled"] = enabled;
|
||||
}
|
||||
|
||||
// Flip every already-listed module based on membership (disables the rest).
|
||||
foreach (var key in enabled.Select(kv => kv.Key).ToList())
|
||||
{
|
||||
enabled[key] = modulesToEnable.Any(m => string.Equals(m, key, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
// Ensure the requested modules are present and enabled even if not previously listed.
|
||||
foreach (var module in modulesToEnable)
|
||||
{
|
||||
enabled[module] = true;
|
||||
}
|
||||
|
||||
File.WriteAllText(GlobalSettingsPath, root.ToJsonString(Indented));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Suppress the first-run "Welcome to PowerToys" (OOBE) and "What's new" (SCOOBE) windows. On a
|
||||
/// fresh profile (e.g. a CI agent) the runner opens one of these centered, topmost windows, which
|
||||
/// steals centre-screen mouse gestures (a coordinate measurement at screen-centre lands on the
|
||||
/// Welcome window instead of the module overlay → empty result). Mirrors the runner's own gating:
|
||||
/// marks OOBE as already opened (<c>oobe_settings.json</c> → <c>openedAtFirstLaunch=true</c>) and
|
||||
/// disables the what's-new-after-updates setting (<c>settings.json</c> →
|
||||
/// <c>show_whats_new_after_updates=false</c>, which the runner honours regardless of version).
|
||||
/// Best-effort — never blocks a test from launching.
|
||||
/// </summary>
|
||||
public static void SuppressFirstRunExperience()
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(PowerToysSettingsRoot);
|
||||
|
||||
// OOBE: mark as already opened so the runner skips the Welcome window.
|
||||
var oobe = new JsonObject { ["openedAtFirstLaunch"] = true };
|
||||
File.WriteAllText(Path.Combine(PowerToysSettingsRoot, "oobe_settings.json"), oobe.ToJsonString(Indented));
|
||||
|
||||
// SCOOBE: disable "what's new after updates" (version-independent) in the general settings.
|
||||
var root = File.Exists(GlobalSettingsPath)
|
||||
? (JsonNode.Parse(File.ReadAllText(GlobalSettingsPath)) as JsonObject) ?? new JsonObject()
|
||||
: new JsonObject();
|
||||
root["show_whats_new_after_updates"] = false;
|
||||
File.WriteAllText(GlobalSettingsPath, root.ToJsonString(Indented));
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort — a fresh-run window is a nuisance, not a reason to fail the test setup.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update a module's <c>settings.json</c>
|
||||
/// (<c>%LocalAppData%\Microsoft\PowerToys\<module>\settings.json</c>). Seeds the file from
|
||||
/// <paramref name="defaultSettingsContent"/> when it doesn't exist, then applies
|
||||
/// <paramref name="updateSettingsAction"/> to the parsed object and writes it back.
|
||||
/// </summary>
|
||||
public static void UpdateModuleSettings(
|
||||
string moduleName,
|
||||
string defaultSettingsContent,
|
||||
Action<JsonObject> updateSettingsAction)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(moduleName);
|
||||
ArgumentNullException.ThrowIfNull(updateSettingsAction);
|
||||
|
||||
var moduleDir = Path.Combine(PowerToysSettingsRoot, moduleName);
|
||||
var settingsPath = Path.Combine(moduleDir, "settings.json");
|
||||
Directory.CreateDirectory(moduleDir);
|
||||
|
||||
var existing = File.Exists(settingsPath) ? File.ReadAllText(settingsPath) : string.Empty;
|
||||
|
||||
JsonObject settings;
|
||||
if (string.IsNullOrWhiteSpace(existing))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(defaultSettingsContent))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"Default settings content must be provided when the file doesn't exist.",
|
||||
nameof(defaultSettingsContent));
|
||||
}
|
||||
|
||||
settings = (JsonNode.Parse(defaultSettingsContent) as JsonObject)
|
||||
?? throw new InvalidOperationException($"Default settings for '{moduleName}' is not a JSON object.");
|
||||
}
|
||||
else
|
||||
{
|
||||
settings = (JsonNode.Parse(existing) as JsonObject)
|
||||
?? throw new InvalidOperationException($"Existing settings for '{moduleName}' is not a JSON object.");
|
||||
}
|
||||
|
||||
updateSettingsAction(settings);
|
||||
|
||||
File.WriteAllText(settingsPath, settings.ToJsonString(Indented));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<!-- Look at Directory.Build.props in root for common stuff as well -->
|
||||
<Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" />
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Library</OutputType>
|
||||
<TargetFramework>net10.0-windows10.0.26100.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<!--
|
||||
WinForms is needed for System.Windows.Forms.SendKeys.SendWait, used by the global-hotkey
|
||||
injection in KeyboardHelper. (Same approach as the legacy harness.)
|
||||
-->
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<RootNamespace>Microsoft.PowerToys.UITest.Next</RootNamespace>
|
||||
<AssemblyName>Microsoft.PowerToys.UITest.Next</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!--
|
||||
Engine is winappcli (Microsoft.WinAppCli) — installed once per machine via
|
||||
`winget install Microsoft.winappcli`. We shell out to winapp.exe and parse its
|
||||
JSON output. No managed dependency on the engine — only MSTest's attribute surface.
|
||||
-->
|
||||
<PackageReference Include="MSTest.TestFramework" />
|
||||
<!--
|
||||
ScreenRecorderLib encodes the optional pipeline screen recording (ScreenRecording.cs)
|
||||
in realtime via native Media Foundation. Pipeline-only diagnostic; no PATH/FFmpeg setup.
|
||||
The package ships a single mixed-mode (managed + native) assembly per architecture under
|
||||
build\<arch>\, wired up by a non-transitive build\*.targets. GeneratePathProperty lets us
|
||||
re-publish that assembly as a copy-local item (below) so it also flows to the test projects
|
||||
that reference this library.
|
||||
-->
|
||||
<PackageReference Include="ScreenRecorderLib" GeneratePathProperty="true" />
|
||||
</ItemGroup>
|
||||
|
||||
<!--
|
||||
Map the build platform to ScreenRecorderLib's per-architecture folder (Win32 -> x86; x64,
|
||||
ARM64 and x86 match by name) and copy the resolved mixed-mode assembly to output as a
|
||||
CopyToOutputDirectory item. Unlike the package's build\*.targets <Reference>, this flows
|
||||
transitively through ProjectReference so consuming UI-test projects also deploy the DLL.
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<ScreenRecorderLibArch>$(Platform)</ScreenRecorderLibArch>
|
||||
<ScreenRecorderLibArch Condition="'$(Platform)' == 'Win32'">x86</ScreenRecorderLibArch>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup Condition="Exists('$(PkgScreenRecorderLib)\build\$(ScreenRecorderLibArch)\ScreenRecorderLib.dll')">
|
||||
<None Include="$(PkgScreenRecorderLib)\build\$(ScreenRecorderLibArch)\ScreenRecorderLib.dll">
|
||||
<Link>ScreenRecorderLib.dll</Link>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
<Visible>false</Visible>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
476
src/common/UITestAutomation.Next/UITestBase.cs
Normal file
476
src/common/UITestAutomation.Next/UITestBase.cs
Normal file
@@ -0,0 +1,476 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace Microsoft.PowerToys.UITest.Next;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for the next-generation PowerToys UI tests. Engine is winappcli — every UI call
|
||||
/// shells out to <c>winapp.exe</c>. No WinAppDriver, no Selenium, no third-party NuGet packages.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Drop-in shape replacement for the existing <c>Microsoft.PowerToys.UITest.UITestBase</c>:
|
||||
/// inherit, pass a <see cref="PowerToysModule"/>, and use <c>Session</c> / <c>Find<T></c> in tests.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Test Explorer integration is automatic — MSTest's <c>[TestClass]</c> / <c>[TestInitialize]</c> /
|
||||
/// <c>[TestCleanup]</c> plus the Microsoft.Testing.Platform runner (enabled repo-wide in
|
||||
/// <c>Directory.Build.props</c>) are everything Test Explorer and <c>dotnet test</c> need.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[TestClass]
|
||||
public class UITestBase : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Lazy one-shot probe for <c>winapp.exe</c>. Runs the first time any UITest in the
|
||||
/// process initializes — the cost is one extra <c>winapp --version</c> call per test run.
|
||||
/// </summary>
|
||||
private static readonly Lazy<bool> CliAvailable = new(WinappCli.IsAvailable);
|
||||
|
||||
// Class-scoped reuse (opt-in via ReuseScopeAcrossTests): the launcher that owns the shared scope
|
||||
// and the test class it belongs to. UI tests never run in parallel, so one slot is enough; the
|
||||
// inherited ClassCleanup stops it once the owning class finishes.
|
||||
private static SessionHelper? keepAliveHelper;
|
||||
private static Type? keepAliveOwner;
|
||||
|
||||
private readonly PowerToysModule scope;
|
||||
private readonly WindowSize windowSize;
|
||||
private readonly string[]? enableModules;
|
||||
private readonly bool isInPipeline = EnvironmentConfig.IsInPipeline;
|
||||
|
||||
private SessionHelper? sessionHelper;
|
||||
private ScreenRecording? screenRecording;
|
||||
private string? recordingDirectory;
|
||||
private bool artifactsCaptured;
|
||||
private bool disposed;
|
||||
|
||||
public required TestContext TestContext { get; set; }
|
||||
|
||||
public Session Session { get; private set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// PowerToys processes killed before every test so each run starts from a clean desktop state
|
||||
/// (mirrors the legacy harness's <c>CloseOtherApplications</c>). Override to extend the list with
|
||||
/// a module's helper processes. Matched by exact name, so short names like "PowerToys" don't hit
|
||||
/// unrelated processes.
|
||||
/// </summary>
|
||||
protected virtual IReadOnlyList<string> StaleProcessNames { get; } = new[]
|
||||
{
|
||||
"PowerToys",
|
||||
"PowerToys.Settings",
|
||||
"PowerToys.FancyZonesEditor",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// When a derived class overrides this to <c>true</c>, the module is launched once for the whole
|
||||
/// class and the <b>same window is reused across every test method</b> (no per-test relaunch or
|
||||
/// desktop hygiene). The framework still captures failure media per test and stops the scope once
|
||||
/// the class finishes. Default <c>false</c> — each test gets an isolated launch + teardown.
|
||||
/// </summary>
|
||||
protected virtual bool ReuseScopeAcrossTests => false;
|
||||
|
||||
/// <param name="scope">Module whose window the test drives.</param>
|
||||
/// <param name="size">Optional fixed window size applied once the window appears.</param>
|
||||
/// <param name="enableModules">
|
||||
/// When non-null, exactly these modules are enabled (and every other listed module disabled) in
|
||||
/// the global <c>settings.json</c> before the runner launches — a deterministic module baseline.
|
||||
/// Leave null to launch against whatever state <c>settings.json</c> already holds.
|
||||
/// </param>
|
||||
protected UITestBase(
|
||||
PowerToysModule scope = PowerToysModule.PowerToysSettings,
|
||||
WindowSize size = WindowSize.UnSpecified,
|
||||
string[]? enableModules = null)
|
||||
{
|
||||
this.scope = scope;
|
||||
this.windowSize = size;
|
||||
this.enableModules = enableModules;
|
||||
}
|
||||
|
||||
[TestInitialize]
|
||||
public async Task TestInit()
|
||||
{
|
||||
if (!CliAvailable.Value)
|
||||
{
|
||||
Assert.Fail(WinappCli.InstallHint);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Reuse the already-open window from a previous test in this class when the class opted
|
||||
// into a shared scope and it's still alive — skip the hygiene that would minimize/kill it.
|
||||
var reuse = ReuseScopeAcrossTests
|
||||
&& keepAliveOwner == GetType()
|
||||
&& SessionHelper.IsRunning(scope);
|
||||
|
||||
if (!reuse)
|
||||
{
|
||||
// Pin the display to a known resolution so coordinate-sensitive tests are
|
||||
// deterministic, and snapshot the monitor topology for post-mortem diagnostics.
|
||||
if (isInPipeline)
|
||||
{
|
||||
DisplayHelper.NormalizeResolution(1920, 1080);
|
||||
DisplayHelper.LogMonitors(TestContext);
|
||||
}
|
||||
|
||||
PreTestHygiene();
|
||||
|
||||
// Seed a deterministic module on/off baseline before the runner reads settings.json.
|
||||
if (enableModules is not null)
|
||||
{
|
||||
SettingsConfigHelper.ConfigureGlobalModuleSettings(enableModules);
|
||||
}
|
||||
}
|
||||
|
||||
// Start the 1s screenshot timer + FFmpeg recording before the UI work so the artifacts
|
||||
// cover the whole test.
|
||||
if (isInPipeline)
|
||||
{
|
||||
StartPipelineCapture();
|
||||
}
|
||||
|
||||
sessionHelper = new SessionHelper(scope);
|
||||
Session = sessionHelper.Init(); // launches when needed; reuses a running instance otherwise
|
||||
|
||||
ApplyWindowSize();
|
||||
|
||||
// Remember the launcher so the inherited ClassCleanup can stop the shared scope at the
|
||||
// end of the class.
|
||||
if (ReuseScopeAcrossTests && !reuse)
|
||||
{
|
||||
keepAliveHelper = sessionHelper;
|
||||
keepAliveOwner = GetType();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// MSTest does NOT run [TestCleanup] when [TestInitialize] throws, so capture the failure
|
||||
// media here (e.g. the window never appeared) before propagating — otherwise an init
|
||||
// failure would attach no diagnostics at all.
|
||||
await CaptureFailureArtifactsAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
[TestCleanup]
|
||||
public async Task TestCleanup()
|
||||
{
|
||||
var failed = TestContext.CurrentTestOutcome is
|
||||
UnitTestOutcome.Failed or UnitTestOutcome.Error or UnitTestOutcome.Unknown;
|
||||
|
||||
if (failed)
|
||||
{
|
||||
await CaptureFailureArtifactsAsync();
|
||||
}
|
||||
else if (isInPipeline)
|
||||
{
|
||||
// Passing test: stop the capture and discard the (now uninteresting) recording.
|
||||
await StopPipelineCaptureAsync();
|
||||
CleanupRecordingDirectory();
|
||||
}
|
||||
|
||||
// Tear the scope down only when each test owns its launch. With a class-shared scope the
|
||||
// window must survive for the next test; the inherited ClassCleanup stops it at class end.
|
||||
if (!ReuseScopeAcrossTests)
|
||||
{
|
||||
try
|
||||
{
|
||||
sessionHelper?.StopIfStarted();
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stop a class-shared scope (see <see cref="ReuseScopeAcrossTests"/>) once the owning class's
|
||||
/// tests finish. Runs after every derived class via inheritance; a no-op for classes that never
|
||||
/// kept a scope alive.
|
||||
/// </summary>
|
||||
[ClassCleanup(InheritanceBehavior.BeforeEachDerivedClass)]
|
||||
public static void StopSharedScope()
|
||||
{
|
||||
try
|
||||
{
|
||||
keepAliveHelper?.StopIfStarted();
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
keepAliveHelper = null;
|
||||
keepAliveOwner = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collect every diagnostic for a failed test and attach it: a window-independent desktop
|
||||
/// screenshot always, plus (in pipeline mode) the 1s screenshot trail, the screen recording, and
|
||||
/// the PowerToys log files. Idempotent and fully tolerant — runs from both the <see cref="TestInit"/>
|
||||
/// failure path (where <c>[TestCleanup]</c> won't fire) and <see cref="TestCleanup"/>.
|
||||
/// </summary>
|
||||
private async Task CaptureFailureArtifactsAsync()
|
||||
{
|
||||
if (artifactsCaptured)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
artifactsCaptured = true;
|
||||
|
||||
if (isInPipeline)
|
||||
{
|
||||
try
|
||||
{
|
||||
await StopPipelineCaptureAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
if (isInPipeline)
|
||||
{
|
||||
try
|
||||
{
|
||||
AddRecordingsToTestResults();
|
||||
AddLogFilesToTestResults();
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bring the desktop to a known state before launching: minimize every window, dismiss any
|
||||
/// lingering popup with <c>Esc</c>, kill the stale PowerToys processes in
|
||||
/// <see cref="StaleProcessNames"/>, and suppress the first-run Welcome/What's-new windows.
|
||||
/// Best-effort — never blocks a test from starting.
|
||||
/// </summary>
|
||||
private void PreTestHygiene()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Minimize all windows so the test starts from a known desktop state.
|
||||
KeyboardHelper.SendKeys(Key.LWin, Key.M);
|
||||
|
||||
// Dismiss any lingering popup / flyout.
|
||||
KeyboardHelper.SendKeys(Key.Esc);
|
||||
|
||||
// Kill stale PowerToys processes so each test launches fresh.
|
||||
foreach (var processName in StaleProcessNames)
|
||||
{
|
||||
WindowControl.TryKillProcessByName(processName);
|
||||
}
|
||||
|
||||
// Stop the runner popping the centered "Welcome to PowerToys" / "What's new" window on a
|
||||
// fresh profile (e.g. CI) — it steals centre-screen mouse gestures from module overlays.
|
||||
SettingsConfigHelper.SuppressFirstRunExperience();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Hygiene is opportunistic; a failure here must not fail the test.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Apply the constructor's <see cref="WindowSize"/> to the resolved window, if any.</summary>
|
||||
private void ApplyWindowSize()
|
||||
{
|
||||
if (Session is null || Session.WindowHandle == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var hwnd = new IntPtr(Session.WindowHandle);
|
||||
if (windowSize == WindowSize.UnSpecified)
|
||||
{
|
||||
// No explicit size requested: maximize so the whole window is on-screen and every control is
|
||||
// reachable. PowerToys restores a module's last window rect, which on a CI agent is often small
|
||||
// or pushed off the side of the screen; for Settings that collapses the NavigationView pane and
|
||||
// breaks nav-item lookups (e.g. SystemToolsNavItem). Maximizing is the deterministic default.
|
||||
WindowHelper.MaximizeWindow(hwnd);
|
||||
}
|
||||
else
|
||||
{
|
||||
WindowHelper.SetWindowSize(hwnd, windowSize);
|
||||
}
|
||||
|
||||
Thread.Sleep(200);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Force a clean restart of the scope (kill + relaunch + rebind to the fresh window), re-seeding
|
||||
/// the module baseline first. Equivalent to the legacy <c>RestartScopeExe</c>; assigns and returns
|
||||
/// the new <see cref="Session"/>.
|
||||
/// </summary>
|
||||
/// <param name="enableModules">
|
||||
/// Modules to enable before relaunch. When null, the baseline passed to the constructor (if any)
|
||||
/// is re-applied so the restart stays deterministic.
|
||||
/// </param>
|
||||
public Session RestartScope(string[]? enableModules = null)
|
||||
{
|
||||
var modules = enableModules ?? this.enableModules;
|
||||
if (modules is not null)
|
||||
{
|
||||
SettingsConfigHelper.ConfigureGlobalModuleSettings(modules);
|
||||
}
|
||||
|
||||
Session = sessionHelper!.Restart();
|
||||
ApplyWindowSize();
|
||||
return Session;
|
||||
}
|
||||
|
||||
// ----- Pipeline diagnostics (CI only) ---------------------------------------------------
|
||||
|
||||
/// <summary>Start the FFmpeg screen recording. Best-effort.</summary>
|
||||
private void StartPipelineCapture()
|
||||
{
|
||||
try
|
||||
{
|
||||
var baseDirectory = TestContext.TestResultsDirectory ?? Path.GetTempPath();
|
||||
|
||||
recordingDirectory = Path.Combine(baseDirectory, "UITestRecordings_" + Guid.NewGuid());
|
||||
Directory.CreateDirectory(recordingDirectory);
|
||||
try
|
||||
{
|
||||
screenRecording = new ScreenRecording(recordingDirectory);
|
||||
if (screenRecording.IsAvailable)
|
||||
{
|
||||
_ = screenRecording.StartRecordingAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
screenRecording = null;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
screenRecording = null;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Capture setup is best-effort; never block the test on it.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Finalize the recording. Best-effort.</summary>
|
||||
private async Task StopPipelineCaptureAsync()
|
||||
{
|
||||
if (screenRecording is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await screenRecording.StopRecordingAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void AddRecordingsToTestResults()
|
||||
{
|
||||
if (recordingDirectory is not null && Directory.Exists(recordingDirectory))
|
||||
{
|
||||
foreach (var file in Directory.GetFiles(recordingDirectory, "*.mp4"))
|
||||
{
|
||||
TestContext.AddResultFile(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void CleanupRecordingDirectory()
|
||||
{
|
||||
if (recordingDirectory is not null && Directory.Exists(recordingDirectory))
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(recordingDirectory, true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Copy PowerToys <c>*.log</c> files (from both <c>%LocalAppData%</c> and <c>%LocalAppDataLow%</c>)
|
||||
/// into the test results so a failed CI run carries the module logs.
|
||||
/// </summary>
|
||||
private void AddLogFilesToTestResults()
|
||||
{
|
||||
try
|
||||
{
|
||||
var localLow = Path.Combine(
|
||||
Environment.GetEnvironmentVariable("USERPROFILE") ?? string.Empty,
|
||||
"AppData", "LocalLow", "Microsoft", "PowerToys");
|
||||
CopyLogFiles(localLow);
|
||||
|
||||
var localAppData = Path.Combine(
|
||||
Environment.GetEnvironmentVariable("LOCALAPPDATA") ?? string.Empty,
|
||||
"Microsoft", "PowerToys");
|
||||
CopyLogFiles(localAppData);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Log collection is diagnostic-only.
|
||||
}
|
||||
}
|
||||
|
||||
private void CopyLogFiles(string sourceDir, string relativePath = "")
|
||||
{
|
||||
if (!Directory.Exists(sourceDir))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var logFile in Directory.GetFiles(sourceDir, "*.log"))
|
||||
{
|
||||
try
|
||||
{
|
||||
var fileName = Path.GetFileName(logFile);
|
||||
var prefix = string.IsNullOrEmpty(relativePath) ? string.Empty : relativePath.Replace("\\", "-") + "-";
|
||||
var destination = Path.Combine(
|
||||
TestContext.TestResultsDirectory ?? Path.GetTempPath(), $"{prefix}{fileName}");
|
||||
File.Copy(logFile, destination, true);
|
||||
TestContext.AddResultFile(destination);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var subdir in Directory.GetDirectories(sourceDir))
|
||||
{
|
||||
var dirName = Path.GetFileName(subdir);
|
||||
var newRelative = string.IsNullOrEmpty(relativePath) ? dirName : Path.Combine(relativePath, dirName);
|
||||
CopyLogFiles(subdir, newRelative);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Find an element on the session's window. Shortcut for <c>Session.Find<T></c>.</summary>
|
||||
protected T Find<T>(By by, int timeoutMS = 5000)
|
||||
where T : Element, new() => Session.Find<T>(by, timeoutMS);
|
||||
|
||||
/// <summary>Find an element by Name. Shortcut for <c>Session.Find<T>(By.Name(name))</c>.</summary>
|
||||
protected T Find<T>(string name, int timeoutMS = 5000)
|
||||
where T : Element, new() => Session.Find<T>(By.Name(name), timeoutMS);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
disposed = true;
|
||||
screenRecording?.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
401
src/common/UITestAutomation.Next/WinappCli.cs
Normal file
401
src/common/UITestAutomation.Next/WinappCli.cs
Normal file
@@ -0,0 +1,401 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace Microsoft.PowerToys.UITest.Next;
|
||||
|
||||
/// <summary>
|
||||
/// Thin wrapper around the winappcli executable. Every public method shells out to
|
||||
/// <c>winapp.exe</c>, captures stdout/stderr/exit-code, and (where requested) parses the
|
||||
/// <c>--json</c> envelope using <see cref="JsonDocument"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Engine prerequisites: install once with <c>winget install Microsoft.winappcli</c>. The CLI
|
||||
/// lands on PATH at <c>%LOCALAPPDATA%\Microsoft\WindowsApps\winapp.exe</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// All invocations set <c>WINAPP_CLI_TELEMETRY_OPTOUT=1</c> and disable update checks via
|
||||
/// <c>WINAPP_CLI_UPDATE_CHECK=0</c> so the CLI never injects extra lines into stdout.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class WinappCli
|
||||
{
|
||||
/// <summary>Stable hint surfaced when the CLI is missing or fails — used in all error paths.</summary>
|
||||
public const string InstallHint =
|
||||
"winapp.exe not found. Install once with: winget install Microsoft.winappcli " +
|
||||
"(or set the WINAPP_CLI_PATH environment variable to its full path).";
|
||||
|
||||
private static readonly Lazy<string> ExecutablePath = new(ResolveExecutable);
|
||||
|
||||
/// <summary>
|
||||
/// Per-invocation guard. A hung <c>winapp.exe</c> call must fail fast and name the offending
|
||||
/// command instead of blocking until the suite's outer timeout fires (which buries the cause).
|
||||
/// Commands that pass a longer <c>-t</c> wait extend this; see <see cref="ResolveInvokeTimeout"/>.
|
||||
/// </summary>
|
||||
private static readonly TimeSpan DefaultInvokeTimeout = TimeSpan.FromSeconds(60);
|
||||
|
||||
/// <summary>
|
||||
/// Serializes winapp.exe invocations. Two CLI UIA clients querying the same target at once can hang
|
||||
/// each other (worst against the live Measure Tool overlay), and the stray-process guard in
|
||||
/// <see cref="Invoke"/> must never race a legitimate in-flight call — so invocations run one at a time.
|
||||
/// </summary>
|
||||
private static readonly object InvokeGate = new();
|
||||
|
||||
public sealed record Result(int ExitCode, string StdOut, string StdErr, IReadOnlyList<string> Args)
|
||||
{
|
||||
public bool Success => ExitCode == 0;
|
||||
|
||||
/// <summary>
|
||||
/// One-line, assertion-friendly description of a failed invocation. Format:
|
||||
/// <c>"winapp ui invoke X -w 12345 -> exit 1; stderr: not found"</c>. Falls back to
|
||||
/// stdout if stderr is empty.
|
||||
/// </summary>
|
||||
public string DescribeFailure()
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("winapp ");
|
||||
sb.AppendJoin(' ', Args);
|
||||
sb.Append(" -> exit ").Append(ExitCode);
|
||||
if (!string.IsNullOrWhiteSpace(StdErr))
|
||||
{
|
||||
sb.Append("; stderr: ").Append(StdErr.Trim());
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(StdOut))
|
||||
{
|
||||
sb.Append("; stdout: ").Append(StdOut.Trim());
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
public JsonDocument ParseJson()
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonDocument.Parse(StdOut);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"winappcli stdout was not valid JSON. {DescribeFailure()}",
|
||||
ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true when <c>winapp.exe</c> resolves to a real file AND responds to
|
||||
/// <c>--version</c>. Use from <c>[ClassInitialize]</c> / <c>[AssemblyInitialize]</c> /
|
||||
/// <see cref="UITestBase"/> to fail the entire suite once with a clear install hint,
|
||||
/// instead of letting every test produce its own opaque process-launch failure.
|
||||
/// </summary>
|
||||
public static bool IsAvailable()
|
||||
{
|
||||
if (!TryResolveExecutable(out _))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return Invoke("--version").Success;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Run <c>winapp.exe</c> with the given arguments. Returns exit code and captured streams.</summary>
|
||||
public static Result Invoke(params string[] args)
|
||||
{
|
||||
// Serialize invocations so two winapp.exe never run at once — and so the stray-process guard in
|
||||
// InvokeLocked can't race a legitimate in-flight call.
|
||||
lock (InvokeGate)
|
||||
{
|
||||
return InvokeLocked(args);
|
||||
}
|
||||
}
|
||||
|
||||
private static Result InvokeLocked(string[] args)
|
||||
{
|
||||
// Before spinning up a new winapp.exe, kill any stray one left behind by a previous
|
||||
// timed-out/killed call: a second UIA client against the same target (e.g. the live Measure
|
||||
// Tool overlay) can wedge the new call. Serialized, so anything alive here is a leftover.
|
||||
KillStrayWinappProcesses(args);
|
||||
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = ExecutablePath.Value,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
StandardOutputEncoding = Encoding.UTF8,
|
||||
StandardErrorEncoding = Encoding.UTF8,
|
||||
};
|
||||
|
||||
// Suppress telemetry banner and update-check notice so --json output stays clean.
|
||||
psi.Environment["WINAPP_CLI_TELEMETRY_OPTOUT"] = "1";
|
||||
psi.Environment["WINAPP_CLI_UPDATE_CHECK"] = "0";
|
||||
|
||||
foreach (var a in args)
|
||||
{
|
||||
psi.ArgumentList.Add(a);
|
||||
}
|
||||
|
||||
var overall = Stopwatch.StartNew();
|
||||
using var p = StartWinappProcess(psi);
|
||||
|
||||
var stdoutTask = p.StandardOutput.ReadToEndAsync();
|
||||
var stderrTask = p.StandardError.ReadToEndAsync();
|
||||
|
||||
var timeout = ResolveInvokeTimeout(args);
|
||||
if (!p.WaitForExit((int)timeout.TotalMilliseconds))
|
||||
{
|
||||
try
|
||||
{
|
||||
Console.WriteLine($"[winappcli] killing hung call after {timeout.TotalSeconds:0}s: winapp {string.Join(' ', args)}");
|
||||
p.Kill(entireProcessTree: true);
|
||||
p.WaitForExit(5000); // make sure the tree is actually gone before returning
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Raced with a natural exit between the wait timing out and the kill — nothing to do.
|
||||
}
|
||||
|
||||
throw new TimeoutException(
|
||||
$"winapp {string.Join(' ', args)} did not exit within {timeout.TotalSeconds:0}s and was killed.");
|
||||
}
|
||||
|
||||
// winapp.exe itself has now exited; capture how long that took.
|
||||
var processMs = overall.ElapsedMilliseconds;
|
||||
|
||||
// Bound the wait for the async stdout/stderr readers. The output is already buffered, but a
|
||||
// child that inherited the redirected pipe keeps the handle open so the readers (and a
|
||||
// parameterless WaitForExit) never see EOF. After a short grace, clear strays — invocations are
|
||||
// serialized, so any winapp.exe alive now is that child — which closes the pipe so the reads
|
||||
// finish with the full, already-captured output.
|
||||
if (!Task.WhenAll(stdoutTask, stderrTask).Wait(TimeSpan.FromSeconds(2)))
|
||||
{
|
||||
Console.WriteLine(
|
||||
"[winappcli] output stalled after winapp.exe exit (lingering child held the pipe); clearing strays for: " +
|
||||
$"winapp {string.Join(' ', args)}");
|
||||
KillStrayWinappProcesses(args);
|
||||
Task.WhenAll(stdoutTask, stderrTask).Wait(TimeSpan.FromSeconds(3));
|
||||
}
|
||||
|
||||
// Surface where slow calls actually spend their time — winapp.exe runtime vs waiting for the
|
||||
// output streams to drain after it exited — so a slow timestamp can be attributed correctly.
|
||||
var totalMs = overall.ElapsedMilliseconds;
|
||||
if (totalMs > 2000)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[winappcli] slow call {totalMs}ms (winapp.exe ran {processMs}ms, output drain {totalMs - processMs}ms): " +
|
||||
$"winapp {string.Join(' ', args)}");
|
||||
}
|
||||
|
||||
return new Result(
|
||||
p.ExitCode,
|
||||
stdoutTask.IsCompletedSuccessfully ? stdoutTask.Result : string.Empty,
|
||||
stderrTask.IsCompletedSuccessfully ? stderrTask.Result : string.Empty,
|
||||
args);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Kill any <c>winapp.exe</c> still running before a new invocation (or when output stalls). A stray
|
||||
/// instance is a leftover from a previous call that timed out / didn't fully exit; a second UIA
|
||||
/// client against the same target can wedge a call, so we clear them and log each kill. Best-effort
|
||||
/// and bounded — never throws.
|
||||
/// </summary>
|
||||
private static void KillStrayWinappProcesses(string[] args)
|
||||
{
|
||||
Process[] strays;
|
||||
try
|
||||
{
|
||||
strays = Process.GetProcessesByName("winapp");
|
||||
}
|
||||
catch
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var stray in strays)
|
||||
{
|
||||
try
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[winappcli] found a stray winapp.exe (pid {stray.Id}) still running; killing it before: " +
|
||||
$"winapp {string.Join(' ', args)}");
|
||||
stray.Kill(entireProcessTree: true);
|
||||
stray.WaitForExit(5000);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// The stray may have exited on its own between enumeration and kill — fine.
|
||||
}
|
||||
finally
|
||||
{
|
||||
stray.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Process-guard budget for one invocation. Defaults to <see cref="DefaultInvokeTimeout"/>; when the
|
||||
/// command carries its own <c>-t</c>/<c>--timeout</c> wait in milliseconds (e.g. <c>wait-for</c>), the
|
||||
/// guard is extended past that wait plus a grace margin so a legitimate long wait isn't killed early.
|
||||
/// </summary>
|
||||
private static TimeSpan ResolveInvokeTimeout(string[] args)
|
||||
{
|
||||
var budget = DefaultInvokeTimeout;
|
||||
for (var i = 0; i < args.Length - 1; i++)
|
||||
{
|
||||
if ((string.Equals(args[i], "-t", StringComparison.Ordinal) ||
|
||||
string.Equals(args[i], "--timeout", StringComparison.Ordinal)) &&
|
||||
int.TryParse(args[i + 1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var ms) &&
|
||||
ms > 0)
|
||||
{
|
||||
var withGrace = TimeSpan.FromMilliseconds(ms) + TimeSpan.FromSeconds(30);
|
||||
if (withGrace > budget)
|
||||
{
|
||||
budget = withGrace;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return budget;
|
||||
}
|
||||
|
||||
/// <summary>Run and throw if the exit code is non-zero. Use for fire-and-forget commands.</summary>
|
||||
public static Result InvokeAssertSuccess(params string[] args)
|
||||
{
|
||||
var r = Invoke(args);
|
||||
Assert.AreEqual(0, r.ExitCode, r.DescribeFailure());
|
||||
return r;
|
||||
}
|
||||
|
||||
/// <summary>Run a <c>--json</c> command and return the parsed root <see cref="JsonElement"/>.</summary>
|
||||
public static JsonElement InvokeJson(params string[] args)
|
||||
{
|
||||
var r = Invoke(args);
|
||||
if (!r.Success)
|
||||
{
|
||||
// Many --json commands (search, wait-for) return exit 1 with a valid envelope on
|
||||
// "no match" / "timed out". Still parse so the caller can branch on envelope fields.
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(r.StdOut);
|
||||
return doc.RootElement.Clone();
|
||||
}
|
||||
catch
|
||||
{
|
||||
Assert.Fail($"{r.DescribeFailure()} (stdout was not JSON)");
|
||||
return default;
|
||||
}
|
||||
}
|
||||
|
||||
using var ok = JsonDocument.Parse(r.StdOut);
|
||||
return ok.RootElement.Clone();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Locate <c>winapp.exe</c> without throwing or asserting. <see cref="IsAvailable"/> uses
|
||||
/// this to probe quietly; the lazy <see cref="ResolveExecutable"/> wraps it for the
|
||||
/// first real call.
|
||||
/// </summary>
|
||||
public static bool TryResolveExecutable(out string path)
|
||||
{
|
||||
// 1) Explicit override (CI / dev convenience).
|
||||
var env = Environment.GetEnvironmentVariable("WINAPP_CLI_PATH");
|
||||
if (!string.IsNullOrEmpty(env) && File.Exists(env))
|
||||
{
|
||||
path = env;
|
||||
return true;
|
||||
}
|
||||
|
||||
// 2) Standard winget install location.
|
||||
var winget = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"Microsoft",
|
||||
"WindowsApps",
|
||||
"winapp.exe");
|
||||
if (File.Exists(winget))
|
||||
{
|
||||
path = winget;
|
||||
return true;
|
||||
}
|
||||
|
||||
// 3) Anything on PATH.
|
||||
var pathEnv = Environment.GetEnvironmentVariable("PATH") ?? string.Empty;
|
||||
foreach (var dir in pathEnv.Split(Path.PathSeparator))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dir))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var candidate = Path.Combine(dir, "winapp.exe");
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
path = candidate;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
path = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Start <c>winapp.exe</c>, retrying the transient launch failure that affects Windows App
|
||||
/// Execution Aliases. The <c>winapp.exe</c> found on PATH is the reparse-point stub under
|
||||
/// <c>%LOCALAPPDATA%\Microsoft\WindowsApps</c>; launching an alias through <c>CreateProcess</c>
|
||||
/// (<c>UseShellExecute = false</c>) intermittently throws <see cref="Win32Exception"/> with
|
||||
/// <c>ERROR_INVALID_PARAMETER</c> (87, "The parameter is incorrect") before the alias resolves.
|
||||
/// The launch is atomic — nothing ran — so retrying with a short backoff is safe and
|
||||
/// idempotent. Other Win32 errors (missing file, access denied) propagate immediately so a
|
||||
/// genuine misconfiguration still fails fast.
|
||||
/// </summary>
|
||||
private static Process StartWinappProcess(ProcessStartInfo psi)
|
||||
{
|
||||
const int maxAttempts = 4;
|
||||
for (int attempt = 1; ; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
return Process.Start(psi) ?? throw new InvalidOperationException(
|
||||
$"Failed to start winapp.exe ({psi.FileName}). {InstallHint}");
|
||||
}
|
||||
catch (Win32Exception ex) when (ex.NativeErrorCode == 87 && attempt < maxAttempts)
|
||||
{
|
||||
// App Execution Alias not resolved yet — back off briefly and retry.
|
||||
Thread.Sleep(100 * attempt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string ResolveExecutable()
|
||||
{
|
||||
if (TryResolveExecutable(out var path))
|
||||
{
|
||||
return path;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(InstallHint);
|
||||
}
|
||||
}
|
||||
342
src/common/UITestAutomation.Next/WindowControl.cs
Normal file
342
src/common/UITestAutomation.Next/WindowControl.cs
Normal file
@@ -0,0 +1,342 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Microsoft.PowerToys.UITest.Next;
|
||||
|
||||
/// <summary>
|
||||
/// Fault-tolerant window cleanup helpers. Every method swallows exceptions and returns a
|
||||
/// boolean — they're designed for test <c>finally</c> blocks where a cleanup failure must
|
||||
/// never mask the real test failure.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// winappcli has no <c>close</c> verb, so closing goes through Win32 <c>WM_CLOSE</c>
|
||||
/// (graceful) with an optional process-kill fallback. Focus uses <c>SetForegroundWindow</c>
|
||||
/// against the HWND that <see cref="WindowsFinder"/> already discovers.
|
||||
/// </remarks>
|
||||
public static class WindowControl
|
||||
{
|
||||
[DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool PostMessageW(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool SetForegroundWindow(IntPtr hWnd);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool IsWindow(IntPtr hWnd);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
private static extern int GetClassNameW(IntPtr hWnd, [Out] char[] lpClassName, int nMaxCount);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
private static extern int GetWindowTextW(IntPtr hWnd, [Out] char[] lpString, int nMaxCount);
|
||||
|
||||
private delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
|
||||
|
||||
private const uint WM_CLOSE = 0x0010;
|
||||
private const int SW_RESTORE = 9;
|
||||
|
||||
/// <summary>
|
||||
/// A top-level window discovered by <see cref="EnumerateProcessWindows"/>: its native handle,
|
||||
/// window class name, and title.
|
||||
/// </summary>
|
||||
public readonly record struct ProcessWindow(IntPtr Hwnd, string ClassName, string Title);
|
||||
|
||||
/// <summary>
|
||||
/// Enumerate the top-level windows owned by any process in <paramref name="processIds"/> using the
|
||||
/// pure Win32 <c>EnumWindows</c> API. Unlike winappcli's UI-Automation-backed <c>list-windows</c>,
|
||||
/// this never attaches a UIA client or walks a window's UIA tree, so it is safe to call against a
|
||||
/// process that is mid screen-capture (e.g. the Measure Tool overlay) without disturbing it.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<ProcessWindow> EnumerateProcessWindows(IReadOnlyCollection<int> processIds)
|
||||
{
|
||||
var result = new List<ProcessWindow>();
|
||||
if (processIds.Count == 0)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
EnumWindows(
|
||||
(hWnd, _) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
GetWindowThreadProcessId(hWnd, out var pid);
|
||||
if (processIds.Contains((int)pid))
|
||||
{
|
||||
result.Add(new ProcessWindow(hWnd, GetWindowClassName(hWnd), GetWindowTitle(hWnd)));
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore any single window we can't read; keep enumerating.
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
IntPtr.Zero);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort: return whatever was collected before the failure.
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string GetWindowClassName(IntPtr hWnd)
|
||||
{
|
||||
var buffer = new char[256];
|
||||
var len = GetClassNameW(hWnd, buffer, buffer.Length);
|
||||
return len > 0 ? new string(buffer, 0, len) : string.Empty;
|
||||
}
|
||||
|
||||
private static string GetWindowTitle(IntPtr hWnd)
|
||||
{
|
||||
var buffer = new char[512];
|
||||
var len = GetWindowTextW(hWnd, buffer, buffer.Length);
|
||||
return len > 0 ? new string(buffer, 0, len) : string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Send <c>WM_CLOSE</c> to every window owned by <paramref name="appNameOrPid"/> and wait
|
||||
/// up to <paramref name="timeoutMS"/> for them to disappear. Tolerant: returns false on
|
||||
/// any failure instead of throwing.
|
||||
/// </summary>
|
||||
public static bool TryCloseByApp(string appNameOrPid, int timeoutMS = 5_000)
|
||||
{
|
||||
try
|
||||
{
|
||||
var windows = WindowsFinder.ListByApp(appNameOrPid);
|
||||
if (windows.Count == 0)
|
||||
{
|
||||
return true; // nothing to close
|
||||
}
|
||||
|
||||
foreach (var w in windows)
|
||||
{
|
||||
TryCloseHwnd(w.Hwnd);
|
||||
}
|
||||
|
||||
var deadline = DateTime.UtcNow + TimeSpan.FromMilliseconds(timeoutMS);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (WindowsFinder.ListByApp(appNameOrPid).Count == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
Thread.Sleep(150);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Send <c>WM_CLOSE</c> to every window matching <paramref name="predicate"/> on the
|
||||
/// process and wait for them to disappear. Use when one process owns several windows and
|
||||
/// only some should be closed (e.g. close the ColorPicker editor but leave the overlay).
|
||||
/// </summary>
|
||||
public static bool TryCloseByApp(string appNameOrPid, Func<WindowsFinder.WindowInfo, bool> predicate, int timeoutMS = 5_000)
|
||||
{
|
||||
try
|
||||
{
|
||||
var targets = WindowsFinder.ListByApp(appNameOrPid).Where(predicate).ToList();
|
||||
if (targets.Count == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach (var w in targets)
|
||||
{
|
||||
TryCloseHwnd(w.Hwnd);
|
||||
}
|
||||
|
||||
var deadline = DateTime.UtcNow + TimeSpan.FromMilliseconds(timeoutMS);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (!WindowsFinder.ListByApp(appNameOrPid).Any(predicate))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
Thread.Sleep(150);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bring the first window owned by <paramref name="appNameOrPid"/> to the foreground.
|
||||
/// If the window is minimized it's first restored. Tolerant.
|
||||
/// </summary>
|
||||
public static bool TryFocusByApp(string appNameOrPid)
|
||||
{
|
||||
try
|
||||
{
|
||||
var w = WindowsFinder.ListByApp(appNameOrPid).FirstOrDefault();
|
||||
if (w is null || w.Hwnd == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var hwnd = new IntPtr(w.Hwnd);
|
||||
if (!IsWindow(hwnd))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
ShowWindow(hwnd, SW_RESTORE);
|
||||
return SetForegroundWindow(hwnd);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cleanup convenience: close every window of <paramref name="closeApp"/> (if any) and
|
||||
/// bring <paramref name="focusApp"/> to the foreground. Mirrors the pattern in the legacy
|
||||
/// <c>TestHelper.CleanupTest</c> (close target window → re-attach to Settings) but does
|
||||
/// not throw, so it's safe to call from a test <c>finally</c>.
|
||||
/// </summary>
|
||||
public static void SafeCloseAndFocus(string closeApp, string focusApp, int closeTimeoutMS = 5_000)
|
||||
{
|
||||
TryCloseByApp(closeApp, closeTimeoutMS);
|
||||
TryFocusByApp(focusApp);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Force-terminate every process whose name contains <paramref name="processNameContains"/>.
|
||||
/// Use only as a last resort when <see cref="TryCloseByApp(string, int)"/> failed and the
|
||||
/// module's window must be gone before the next test starts.
|
||||
/// </summary>
|
||||
public static bool TryKillProcess(string processNameContains)
|
||||
{
|
||||
try
|
||||
{
|
||||
var hits = Process.GetProcesses()
|
||||
.Where(p =>
|
||||
{
|
||||
try
|
||||
{
|
||||
return p.ProcessName.Contains(processNameContains, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.ToList();
|
||||
|
||||
foreach (var p in hits)
|
||||
{
|
||||
try
|
||||
{
|
||||
p.Kill(entireProcessTree: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best effort.
|
||||
}
|
||||
finally
|
||||
{
|
||||
p.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
return hits.Count > 0;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Force-terminate every process whose name <b>exactly</b> equals <paramref name="exactProcessName"/>
|
||||
/// (no extension, case-insensitive — the form <see cref="Process.GetProcessesByName(string)"/> accepts).
|
||||
/// Prefer this over <see cref="TryKillProcess"/> for short names like "PowerToys" that are a
|
||||
/// substring of unrelated processes (e.g. a "PowerToys.*.UITests" test host the run is executing
|
||||
/// in). Tolerant — returns false on any failure instead of throwing.
|
||||
/// </summary>
|
||||
public static bool TryKillProcessByName(string exactProcessName)
|
||||
{
|
||||
try
|
||||
{
|
||||
var hits = Process.GetProcessesByName(exactProcessName);
|
||||
foreach (var p in hits)
|
||||
{
|
||||
try
|
||||
{
|
||||
p.Kill(entireProcessTree: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best effort.
|
||||
}
|
||||
finally
|
||||
{
|
||||
p.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
return hits.Length > 0;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void TryCloseHwnd(long hwnd)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (hwnd == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var handle = new IntPtr(hwnd);
|
||||
if (IsWindow(handle))
|
||||
{
|
||||
PostMessageW(handle, WM_CLOSE, IntPtr.Zero, IntPtr.Zero);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best effort.
|
||||
}
|
||||
}
|
||||
}
|
||||
189
src/common/UITestAutomation.Next/WindowHelper.cs
Normal file
189
src/common/UITestAutomation.Next/WindowHelper.cs
Normal file
@@ -0,0 +1,189 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Drawing;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Microsoft.PowerToys.UITest.Next;
|
||||
|
||||
/// <summary>Preset window sizes for <see cref="WindowHelper.SetWindowSize(IntPtr, WindowSize)"/>.</summary>
|
||||
public enum WindowSize
|
||||
{
|
||||
/// <summary>No size change.</summary>
|
||||
UnSpecified,
|
||||
|
||||
/// <summary>640 x 480.</summary>
|
||||
Small,
|
||||
|
||||
/// <summary>480 x 640.</summary>
|
||||
Small_Vertical,
|
||||
|
||||
/// <summary>1024 x 768.</summary>
|
||||
Medium,
|
||||
|
||||
/// <summary>768 x 1024.</summary>
|
||||
Medium_Vertical,
|
||||
|
||||
/// <summary>1920 x 1080.</summary>
|
||||
Large,
|
||||
|
||||
/// <summary>1080 x 1920.</summary>
|
||||
Large_Vertical,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Win32 window + screen helpers for scenarios winappcli can't express: resizing/positioning a
|
||||
/// window, reading a screen pixel color, and querying display geometry. Window discovery itself
|
||||
/// stays CLI-first (<see cref="WindowsFinder"/>; <see cref="IsWindowOpen"/>).
|
||||
/// </summary>
|
||||
public static class WindowHelper
|
||||
{
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct RECT
|
||||
{
|
||||
public int Left;
|
||||
public int Top;
|
||||
public int Right;
|
||||
public int Bottom;
|
||||
}
|
||||
|
||||
private const uint SWP_NOMOVE = 0x0002;
|
||||
private const uint SWP_NOZORDER = 0x0004;
|
||||
private const uint SWP_NOACTIVATE = 0x0010;
|
||||
private const int SM_CXSCREEN = 0;
|
||||
private const int SM_CYSCREEN = 1;
|
||||
private const int SW_MAXIMIZE = 3;
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int x, int y, int cx, int cy, uint uFlags);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern int GetSystemMetrics(int nIndex);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern IntPtr GetDC(IntPtr hWnd);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern int ReleaseDC(IntPtr hWnd, IntPtr hDC);
|
||||
|
||||
[DllImport("gdi32.dll")]
|
||||
private static extern uint GetPixel(IntPtr hdc, int x, int y);
|
||||
|
||||
/// <summary>True when any UIA-visible window's title contains <paramref name="titleContains"/> (CLI-based).</summary>
|
||||
public static bool IsWindowOpen(string titleContains) =>
|
||||
WindowsFinder.ListAll().Any(w => w.Title.Contains(titleContains, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
/// <summary>
|
||||
/// Resize a window to a preset <see cref="WindowSize"/> and CENTER it on the primary display.
|
||||
/// The preset is first clamped to ~90% of the display, so a fixed size (e.g. Large = 1920x1080)
|
||||
/// can't spill off the edges of an equally-sized (1920x1080) display once positioned at a
|
||||
/// non-origin top-left — the cause of the "shifted right and bottom, partially off-screen"
|
||||
/// Settings window. On a larger display the preset size is used as-is, just centered.
|
||||
/// </summary>
|
||||
public static void SetWindowSize(IntPtr hWnd, WindowSize size)
|
||||
{
|
||||
var (w, h) = Dimensions(size);
|
||||
if (w <= 0 || h <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var (screenW, screenH) = GetDisplaySize();
|
||||
|
||||
// Clamp to ~90% of the screen so there's always a visible margin on every edge.
|
||||
int cw = screenW > 0 ? Math.Min(w, (int)(screenW * 0.9)) : w;
|
||||
int ch = screenH > 0 ? Math.Min(h, (int)(screenH * 0.9)) : h;
|
||||
|
||||
// Center on the primary display (never negative, so the title bar stays reachable).
|
||||
int x = Math.Max(0, (screenW - cw) / 2);
|
||||
int y = Math.Max(0, (screenH - ch) / 2);
|
||||
|
||||
SetWindowPos(hWnd, IntPtr.Zero, x, y, cw, ch, SWP_NOZORDER | SWP_NOACTIVATE);
|
||||
}
|
||||
|
||||
/// <summary>Resize a window to explicit width/height, keeping its current position (no move).</summary>
|
||||
public static void SetMainWindowSize(IntPtr hWnd, int width, int height) =>
|
||||
SetWindowPos(hWnd, IntPtr.Zero, 0, 0, width, height, SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE);
|
||||
|
||||
/// <summary>
|
||||
/// Maximize a window so it fills the monitor work area and is fully on-screen. Used as the default
|
||||
/// window state for tests so a module's restored (possibly small or off-screen) last window rect
|
||||
/// can't hide controls such as the Settings NavigationView pane.
|
||||
/// </summary>
|
||||
public static void MaximizeWindow(IntPtr hWnd) => ShowWindow(hWnd, SW_MAXIMIZE);
|
||||
|
||||
/// <summary>(Left, Top, Right, Bottom) of the window in screen pixels.</summary>
|
||||
public static (int Left, int Top, int Right, int Bottom) GetWindowBounds(IntPtr hWnd)
|
||||
{
|
||||
if (GetWindowRect(hWnd, out var r))
|
||||
{
|
||||
return (r.Left, r.Top, r.Right, r.Bottom);
|
||||
}
|
||||
|
||||
return (0, 0, 0, 0);
|
||||
}
|
||||
|
||||
/// <summary>Center point of the window in screen pixels.</summary>
|
||||
public static (int CenterX, int CenterY) GetWindowCenter(IntPtr hWnd)
|
||||
{
|
||||
var (l, t, rgt, b) = GetWindowBounds(hWnd);
|
||||
return (l + ((rgt - l) / 2), t + ((b - t) / 2));
|
||||
}
|
||||
|
||||
/// <summary>Primary display size in pixels.</summary>
|
||||
public static (int Width, int Height) GetDisplaySize() =>
|
||||
(GetSystemMetrics(SM_CXSCREEN), GetSystemMetrics(SM_CYSCREEN));
|
||||
|
||||
/// <summary>Center of the primary display in pixels.</summary>
|
||||
public static (int CenterX, int CenterY) GetScreenCenter()
|
||||
{
|
||||
var (w, h) = GetDisplaySize();
|
||||
return (w / 2, h / 2);
|
||||
}
|
||||
|
||||
/// <summary>Color of the on-screen pixel at (<paramref name="x"/>, <paramref name="y"/>) via GDI.</summary>
|
||||
public static Color GetPixelColor(int x, int y)
|
||||
{
|
||||
var hdc = GetDC(IntPtr.Zero);
|
||||
try
|
||||
{
|
||||
var pixel = GetPixel(hdc, x, y);
|
||||
int r = (int)(pixel & 0x000000FF);
|
||||
int g = (int)((pixel & 0x0000FF00) >> 8);
|
||||
int b = (int)((pixel & 0x00FF0000) >> 16);
|
||||
return Color.FromArgb(r, g, b);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ReleaseDC(IntPtr.Zero, hdc);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>On-screen pixel color at (<paramref name="x"/>, <paramref name="y"/>) as <c>#RRGGBB</c>.</summary>
|
||||
public static string GetPixelColorHex(int x, int y)
|
||||
{
|
||||
var c = GetPixelColor(x, y);
|
||||
return $"#{c.R:X2}{c.G:X2}{c.B:X2}";
|
||||
}
|
||||
|
||||
private static (int Width, int Height) Dimensions(WindowSize size) => size switch
|
||||
{
|
||||
WindowSize.Small => (640, 480),
|
||||
WindowSize.Small_Vertical => (480, 640),
|
||||
WindowSize.Medium => (1024, 768),
|
||||
WindowSize.Medium_Vertical => (768, 1024),
|
||||
WindowSize.Large => (1920, 1080),
|
||||
WindowSize.Large_Vertical => (1080, 1920),
|
||||
_ => (0, 0),
|
||||
};
|
||||
}
|
||||
155
src/common/UITestAutomation.Next/Windows.cs
Normal file
155
src/common/UITestAutomation.Next/Windows.cs
Normal file
@@ -0,0 +1,155 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace Microsoft.PowerToys.UITest.Next;
|
||||
|
||||
/// <summary>
|
||||
/// Static helpers for discovering and attaching to windows that aren't the test's primary scope.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Most tests target one module's main window (handled by <see cref="UITestBase"/> + <see cref="SessionHelper"/>).
|
||||
/// But scenarios like "send the ColorPicker hotkey and assert the Editor pops up" need to discover
|
||||
/// a brand-new window that may not exist when the test starts. These helpers wrap
|
||||
/// <c>winapp ui list-windows --json</c> to find/wait for those windows by process or title.
|
||||
/// </remarks>
|
||||
public static class WindowsFinder
|
||||
{
|
||||
public sealed record WindowInfo(long Hwnd, string Title, string ProcessName, int ProcessId, string ClassName, int Width, int Height);
|
||||
|
||||
/// <summary>List all UIA-visible windows.</summary>
|
||||
/// <remarks>
|
||||
/// NOTE: winappcli's unfiltered <c>list-windows --json</c> currently omits windows that have
|
||||
/// no Win32 title (e.g. the ColorPicker editor exposes its name only via UIA Name, not the
|
||||
/// HWND title). Use <see cref="ListByApp"/> with a process/PID filter when you need to see
|
||||
/// those — winappcli returns them in the filtered form.
|
||||
/// </remarks>
|
||||
public static IReadOnlyList<WindowInfo> ListAll() => Parse(WinappCli.Invoke("ui", "list-windows", "--json"));
|
||||
|
||||
/// <summary>
|
||||
/// List UIA-visible windows belonging to <paramref name="appNameOrPid"/> (process name substring or PID).
|
||||
/// Uses winappcli's <c>-a</c> filter, which works around the bug where unfiltered
|
||||
/// <c>list-windows</c> drops windows without a Win32 title.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<WindowInfo> ListByApp(string appNameOrPid) =>
|
||||
Parse(WinappCli.Invoke("ui", "list-windows", "-a", appNameOrPid, "--json"));
|
||||
|
||||
private static IReadOnlyList<WindowInfo> Parse(WinappCli.Result r)
|
||||
{
|
||||
if (!r.Success || string.IsNullOrEmpty(r.StdOut))
|
||||
{
|
||||
return Array.Empty<WindowInfo>();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(r.StdOut);
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return Array.Empty<WindowInfo>();
|
||||
}
|
||||
|
||||
var list = new List<WindowInfo>();
|
||||
foreach (var w in doc.RootElement.EnumerateArray())
|
||||
{
|
||||
list.Add(new WindowInfo(
|
||||
Hwnd: w.TryGetProperty("hwnd", out var h) && h.ValueKind == JsonValueKind.Number ? h.GetInt64() : 0,
|
||||
Title: w.TryGetProperty("title", out var t) ? (t.GetString() ?? string.Empty) : string.Empty,
|
||||
ProcessName: w.TryGetProperty("processName", out var pn) ? (pn.GetString() ?? string.Empty) : string.Empty,
|
||||
ProcessId: w.TryGetProperty("processId", out var pid) && pid.ValueKind == JsonValueKind.Number ? pid.GetInt32() : 0,
|
||||
ClassName: w.TryGetProperty("className", out var cn) ? (cn.GetString() ?? string.Empty) : string.Empty,
|
||||
Width: w.TryGetProperty("width", out var ww) && ww.ValueKind == JsonValueKind.Number ? ww.GetInt32() : 0,
|
||||
Height: w.TryGetProperty("height", out var hh) && hh.ValueKind == JsonValueKind.Number ? hh.GetInt32() : 0));
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Array.Empty<WindowInfo>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Poll until a window matching <paramref name="predicate"/> appears, or <paramref name="timeoutMS"/>
|
||||
/// elapses. Returns the window's <see cref="Session"/> wrapper on success.
|
||||
/// </summary>
|
||||
public static Session? WaitForWindow(Func<WindowInfo, bool> predicate, PowerToysModule attributeAs = PowerToysModule.Runner, int timeoutMS = 10_000, int pollIntervalMS = 250)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + TimeSpan.FromMilliseconds(timeoutMS);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
foreach (var w in ListAll())
|
||||
{
|
||||
Debug.WriteLine(w.ToString());
|
||||
if (predicate(w))
|
||||
{
|
||||
return new Session(attributeAs, w.Hwnd, w.Title, w.ProcessId, w.ProcessName);
|
||||
}
|
||||
}
|
||||
|
||||
Thread.Sleep(pollIntervalMS);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Convenience wrapper: wait for a window with the given title substring.</summary>
|
||||
public static Session? WaitForWindowByTitle(string titleContains, int timeoutMS = 10_000)
|
||||
=> WaitForWindow(w => w.Title.Contains(titleContains, StringComparison.OrdinalIgnoreCase), timeoutMS: timeoutMS);
|
||||
|
||||
/// <summary>
|
||||
/// Wait for any window owned by a process whose name contains <paramref name="processNameContains"/>.
|
||||
/// Uses winappcli's <c>-a</c> filter under the hood so untitled windows (e.g. the ColorPicker
|
||||
/// editor) are discoverable — the unfiltered <c>list-windows</c> drops those.
|
||||
/// </summary>
|
||||
public static Session? WaitForWindowByProcess(string processNameContains, int timeoutMS = 10_000, int pollIntervalMS = 250)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + TimeSpan.FromMilliseconds(timeoutMS);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
foreach (var w in ListByApp(processNameContains))
|
||||
{
|
||||
Debug.WriteLine(w.ToString());
|
||||
return new Session(PowerToysModule.Runner, w.Hwnd, w.Title, w.ProcessId, w.ProcessName);
|
||||
}
|
||||
|
||||
Thread.Sleep(pollIntervalMS);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Same as <see cref="WaitForWindowByProcess"/> but filters with <paramref name="predicate"/>.
|
||||
/// Use when the same process owns multiple windows (e.g. ColorPickerUI exposes both the
|
||||
/// small picker overlay and the larger editor window).
|
||||
/// </summary>
|
||||
public static Session? WaitForWindowByApp(
|
||||
string appNameOrPid,
|
||||
Func<WindowInfo, bool> predicate,
|
||||
int timeoutMS = 10_000,
|
||||
int pollIntervalMS = 250)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + TimeSpan.FromMilliseconds(timeoutMS);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
foreach (var w in ListByApp(appNameOrPid))
|
||||
{
|
||||
Debug.WriteLine(w.ToString());
|
||||
if (predicate(w))
|
||||
{
|
||||
return new Session(PowerToysModule.Runner, w.Hwnd, w.Title, w.ProcessId, w.ProcessName);
|
||||
}
|
||||
}
|
||||
|
||||
Thread.Sleep(pollIntervalMS);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -30,12 +30,30 @@ namespace Microsoft.PowerToys.UITest
|
||||
/// </summary>
|
||||
public string GetDevelopmentPath()
|
||||
{
|
||||
// The test assembly normally lives in <buildRoot>\tests\<project>\<tfm>\, so the build
|
||||
// output root that holds the module exe is three levels above it. When a test project is
|
||||
// built with a RuntimeIdentifier (OutputType=Exe for the MTP runner) the output gains an
|
||||
// extra RID subfolder (<tfm>\win-x64\ or \win-arm64\), pushing the root one level further
|
||||
// up. Detect that case so the relative path stays correct in both layouts.
|
||||
string prefix = IsRuntimeIdentifierOutputFolder() ? @"\..\..\..\.." : @"\..\..\..";
|
||||
|
||||
if (string.IsNullOrEmpty(SubDirectory))
|
||||
{
|
||||
return $@"\..\..\..\{ExecutableName}";
|
||||
return $@"{prefix}\{ExecutableName}";
|
||||
}
|
||||
|
||||
return $@"\..\..\..\{SubDirectory}\{ExecutableName}";
|
||||
return $@"{prefix}\{SubDirectory}\{ExecutableName}";
|
||||
}
|
||||
|
||||
// True when the executing assembly sits in a RID-specific output subfolder (e.g. ...\<tfm>\win-x64),
|
||||
// which a project with a RuntimeIdentifier produces. Used to keep GetDevelopmentPath's relative
|
||||
// walk-up correct whether or not the RID subfolder is present.
|
||||
private static bool IsRuntimeIdentifierOutputFolder()
|
||||
{
|
||||
var baseDir = AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
var leaf = Path.GetFileName(baseDir);
|
||||
return leaf.Equals("win-x64", StringComparison.OrdinalIgnoreCase)
|
||||
|| leaf.Equals("win-arm64", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -362,14 +362,72 @@ namespace Microsoft.PowerToys.UITest
|
||||
|
||||
private void StartWindowsAppDriverApp()
|
||||
{
|
||||
// Reuse an already-running WinAppDriver — one started once per job by the pipeline
|
||||
// ("Start WinAppDriver" step), or by an earlier test in this assembly — instead of killing
|
||||
// and relaunching it. Only spin up a fresh instance when nothing is listening on :4723.
|
||||
if (IsWinAppDriverListening())
|
||||
{
|
||||
SessionHelper.appDriver ??= Process.GetProcessesByName("WinAppDriver").FirstOrDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
var winAppDriverProcessInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "C:\\Program Files (x86)\\Windows Application Driver\\WinAppDriver.exe",
|
||||
Verb = "runas",
|
||||
|
||||
// WinAppDriver ends its Main with "Press ENTER to exit" + Console.ReadLine(). Under the
|
||||
// Microsoft.Testing.Platform test host the child inherits a stdin that is already at EOF,
|
||||
// so that read returns immediately and WinAppDriver prints "Exiting..." and dies right
|
||||
// after it starts listening — which is what forced the previous launch to keep
|
||||
// relaunching it (and made the very first connection racy). Redirecting stdin and NEVER
|
||||
// closing the pipe makes that read block, so the server stays alive for the whole test
|
||||
// process and is reused by every test in this assembly. Redirect requires
|
||||
// UseShellExecute = false; the default endpoint 127.0.0.1:4723 needs no elevation (only a
|
||||
// custom IP/port does, per WinAppDriver's docs), so "runas" is not needed.
|
||||
UseShellExecute = false,
|
||||
RedirectStandardInput = true,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
|
||||
this.ExitExe(winAppDriverProcessInfo.FileName);
|
||||
SessionHelper.appDriver = Process.Start(winAppDriverProcessInfo);
|
||||
|
||||
// Intentionally do NOT close appDriver.StandardInput: the open pipe is exactly what blocks
|
||||
// WinAppDriver's stdin read and keeps the server alive. The static appDriver reference holds
|
||||
// the pipe open until the test process exits, at which point WinAppDriver shuts down cleanly.
|
||||
|
||||
// WinAppDriver needs a moment to open its HTTP listener on :4723. Connecting immediately races
|
||||
// that startup, so wait until the port accepts a connection before returning.
|
||||
WaitForWinAppDriverReady();
|
||||
}
|
||||
|
||||
// True when something is already accepting connections on the WinAppDriver port (127.0.0.1:4723).
|
||||
private static bool IsWinAppDriverListening()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var client = new System.Net.Sockets.TcpClient();
|
||||
client.Connect("127.0.0.1", 4723);
|
||||
return client.Connected;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void WaitForWinAppDriverReady(int timeoutMs = 30000)
|
||||
{
|
||||
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (IsWinAppDriverListening())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
System.Threading.Thread.Sleep(500);
|
||||
}
|
||||
}
|
||||
|
||||
private void KillPowerToysProcesses()
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using Microsoft.PowerToys.UITest.Next;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace ScreenRuler.UITests.Next;
|
||||
|
||||
/// <summary>
|
||||
/// Verbose per-test execution log. Every <see cref="Step"/> is timestamped with the elapsed seconds
|
||||
/// since the test began, echoed to the <see cref="TestContext"/> (so it shows inline in the run
|
||||
/// output) AND accumulated; <see cref="Save"/> writes the whole thing out as a
|
||||
/// <c>TestExecutionLog_*.log</c> result artifact for post-mortem on CI. ScreenRuler UI tests run
|
||||
/// sequentially, so a single ambient instance (see <c>TestHelper</c>) is safe.
|
||||
/// </summary>
|
||||
internal sealed class DiagnosticLogger
|
||||
{
|
||||
private readonly UITestBase testBase;
|
||||
private readonly string testName;
|
||||
private readonly StringBuilder buffer = new();
|
||||
private readonly Stopwatch stopwatch = Stopwatch.StartNew();
|
||||
|
||||
public DiagnosticLogger(UITestBase testBase, string testName)
|
||||
{
|
||||
this.testBase = testBase;
|
||||
this.testName = testName;
|
||||
Step($"===== {testName}: execution log started =====");
|
||||
}
|
||||
|
||||
/// <summary>Append one timestamped step, echoing it to the TestContext immediately.</summary>
|
||||
public void Step(string message)
|
||||
{
|
||||
var line = $"[+{stopwatch.Elapsed.TotalSeconds,8:F2}s] {message}";
|
||||
buffer.AppendLine(line);
|
||||
try
|
||||
{
|
||||
testBase.TestContext.WriteLine(line);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// TestContext can be unavailable late in teardown — the buffered copy is still saved.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Flush the whole log to a result-attached file artifact (best-effort).</summary>
|
||||
public void Save()
|
||||
{
|
||||
Step($"===== {testName}: execution log ended =====");
|
||||
try
|
||||
{
|
||||
var dir = testBase.TestContext.TestResultsDirectory ?? Path.GetTempPath();
|
||||
Directory.CreateDirectory(dir);
|
||||
var safeName = string.Concat((testName ?? "test").Split(Path.GetInvalidFileNameChars()));
|
||||
var file = Path.Combine(dir, $"TestExecutionLog_{safeName}_{DateTime.Now:yyyyMMdd_HHmmss_fff}.log");
|
||||
File.WriteAllText(file, buffer.ToString());
|
||||
testBase.TestContext.AddResultFile(file);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort artifact; the inline TestContext copy is the fallback.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<!-- Look at Directory.Build.props in root for common stuff as well -->
|
||||
<Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" />
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0-windows10.0.26100.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<RootNamespace>Microsoft.ScreenRuler.UITests</RootNamespace>
|
||||
<AssemblyName>ScreenRuler.UITests.Next</AssemblyName>
|
||||
|
||||
<!-- Per-monitor (V2) DPI awareness so MouseHelper's SetCursorPos coordinates are PHYSICAL
|
||||
pixels that match winappcli's reported bounds. Required for coordinate-exact tests
|
||||
(the Bounds drag measurement). -->
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
|
||||
<!--
|
||||
Microsoft.Testing.Platform: same modern runner Directory.Build.props enables for the rest
|
||||
of the repo, so this test class appears in Test Explorer AND can be run via
|
||||
`dotnet test` / `dotnet run` / `vstest.console.exe`.
|
||||
-->
|
||||
<IsTestingPlatformApplication>true</IsTestingPlatformApplication>
|
||||
<EnableMSTestRunner>true</EnableMSTestRunner>
|
||||
<GenerateDocumentationFile>false</GenerateDocumentationFile>
|
||||
|
||||
<!-- UI tests need a live desktop; never run them as part of MSBuild. -->
|
||||
<RunVSTest>false</RunVSTest>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Stage the built test app under <Platform>\<Configuration>\tests\ so the UI-tests build
|
||||
pipeline (CopyFiles glob **/<plat>/<config>/tests/**) picks it up, matching the other
|
||||
*.UITests projects. Without this it builds to bin\ and is never staged into the artifact. -->
|
||||
<PropertyGroup>
|
||||
<OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\tests\ScreenRuler.UITests.Next\</OutputPath>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MSTest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\common\UITestAutomation.Next\UITestAutomation.Next.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,32 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.PowerToys.UITest.Next;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace ScreenRuler.UITests.Next;
|
||||
|
||||
[TestClass]
|
||||
public class TestBounds : UITestBase
|
||||
{
|
||||
public TestBounds()
|
||||
: base(PowerToysModule.PowerToysSettings, WindowSize.Large, enableModules: new[] { TestHelper.ModuleSettingsKey })
|
||||
{
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("Spacing")]
|
||||
public void TestScreenRulerBoundsTool()
|
||||
{
|
||||
TestHelper.InitializeTest(this, "bounds test");
|
||||
try
|
||||
{
|
||||
TestHelper.PerformBoundsToolTest(this);
|
||||
}
|
||||
finally
|
||||
{
|
||||
TestHelper.CleanupTest(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.PowerToys.UITest.Next;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace ScreenRuler.UITests.Next;
|
||||
|
||||
/// <summary>
|
||||
/// Shared helpers for the Screen Ruler <c>.Next</c> UI tests. Ported from the legacy
|
||||
/// <c>ScreenRuler.UITests/TestHelper.cs</c> (WinAppDriver) to the winappcli harness.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Key differences from the legacy helper:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>The Settings tree is driven through <c>testBase.Session</c> (the Settings
|
||||
/// window). The Screen Ruler toolbar buttons live in a <b>different process/window</b>
|
||||
/// (<c>PowerToys.MeasureToolUI</c>), so they're found through a process-scoped
|
||||
/// <see cref="Session.FromProcess(string, PowerToysModule, int)"/> session — the winappcli
|
||||
/// equivalent of the legacy <c>global: true</c> Find.</description></item>
|
||||
/// <item><description>Mouse / keyboard / clipboard go through the static
|
||||
/// <c>MouseHelper</c> / <c>KeyboardHelper</c> / <c>ClipboardHelper</c> instead of instance
|
||||
/// methods on <c>Session</c> / <c>UITestBase</c>.</description></item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
public static class TestHelper
|
||||
{
|
||||
private static readonly string[] ShortcutSeparators = { " + ", "+", " " };
|
||||
|
||||
// Button automation ids from the Measure Tool's Resources.resw.
|
||||
public const string BoundsButtonId = "Button_Bounds";
|
||||
public const string SpacingButtonName = "Button_Spacing";
|
||||
public const string HorizontalSpacingButtonName = "Button_SpacingHorizontal";
|
||||
public const string VerticalSpacingButtonName = "Button_SpacingVertical";
|
||||
public const string CloseButtonId = "Button_Close";
|
||||
|
||||
// The Measure Tool UI process (the toolbar + measurement overlays). NOTE: the window TITLE is
|
||||
// "PowerToys.ScreenRuler", but the PROCESS name winappcli's -a flag needs is "PowerToys.MeasureToolUI".
|
||||
public const string ScreenRulerProcess = "PowerToys.MeasureToolUI";
|
||||
|
||||
// The module's key in the global settings.json "enabled" section (note the space). Pass this to
|
||||
// the UITestBase ctor's enableModules so the runner boots ONLY this module — much faster on a
|
||||
// fresh profile (CI), where otherwise all ~30 modules start, and more isolated (no other module's
|
||||
// hotkeys/overlays interfere).
|
||||
public const string ModuleSettingsKey = "Measure Tool";
|
||||
|
||||
// Ambient per-test diagnostics. ScreenRuler UI tests run sequentially, so a single ambient
|
||||
// instance is safe. The logger is created in InitializeTest and flushed (as a TestExecutionLog
|
||||
// artifact) in CleanupTest; Log(...) is a no-op when no test is active.
|
||||
private static DiagnosticLogger? log;
|
||||
|
||||
/// <summary>Append a verbose, timestamped step to the current test's execution log.</summary>
|
||||
private static void Log(string message) => log?.Step(message);
|
||||
|
||||
/// <summary>Navigate to the Screen Ruler settings page, enable the toggle, and read the shortcut.</summary>
|
||||
public static Key[] InitializeTest(UITestBase testBase, string testName)
|
||||
{
|
||||
log = new DiagnosticLogger(testBase, testName);
|
||||
|
||||
Log("InitializeTest: navigating to the Screen Ruler settings page");
|
||||
LaunchFromSetting(testBase);
|
||||
|
||||
Log("InitializeTest: enabling the Screen Ruler toggle");
|
||||
var toggleSwitch = SetScreenRulerToggle(testBase, enable: true);
|
||||
Assert.IsTrue(toggleSwitch.IsOn, $"Screen Ruler toggle switch should be ON for {testName}");
|
||||
|
||||
var activationKeys = ReadActivationShortcut(testBase);
|
||||
Assert.IsNotNull(activationKeys, "Should be able to read activation shortcut");
|
||||
Assert.IsTrue(activationKeys.Length > 0, "Activation shortcut should contain at least one key");
|
||||
|
||||
Log($"InitializeTest: ready; activation shortcut = {string.Join(" + ", activationKeys)}");
|
||||
return activationKeys;
|
||||
}
|
||||
|
||||
/// <summary>Close the Screen Ruler UI (best-effort) and flush the execution-log artifact.</summary>
|
||||
public static void CleanupTest(UITestBase testBase)
|
||||
{
|
||||
try
|
||||
{
|
||||
Log("CleanupTest: closing the Screen Ruler UI");
|
||||
CloseScreenRulerUI(testBase);
|
||||
}
|
||||
finally
|
||||
{
|
||||
log?.Save();
|
||||
log = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Navigate to the Screen Ruler (Measure Tool) settings page.</summary>
|
||||
public static void LaunchFromSetting(UITestBase testBase)
|
||||
{
|
||||
// The "System Tools" group is collapsed by default, so the Screen Ruler child item isn't in
|
||||
// the tree until the group is expanded. Expand it only when the child isn't already present.
|
||||
if (!testBase.Session.Has(By.AccessibilityId("ScreenRulerNavItem"), 500))
|
||||
{
|
||||
testBase.Session.Find<NavigationViewItem>(By.AccessibilityId("SystemToolsNavItem"), 5000).Click(msPostAction: 500);
|
||||
}
|
||||
|
||||
testBase.Session.Find<NavigationViewItem>(By.AccessibilityId("ScreenRulerNavItem"), 5000).Click(msPostAction: 800);
|
||||
}
|
||||
|
||||
/// <summary>Set the Screen Ruler toggle to the requested state.</summary>
|
||||
public static ToggleSwitch SetScreenRulerToggle(UITestBase testBase, bool enable)
|
||||
{
|
||||
var toggleSwitch = testBase.Session.Find<ToggleSwitch>(By.AccessibilityId("Toggle_ScreenRuler"), 5000);
|
||||
toggleSwitch.Toggle(enable);
|
||||
toggleSwitch.WaitForProperty("ToggleState", enable ? "On" : "Off", 5000);
|
||||
return toggleSwitch;
|
||||
}
|
||||
|
||||
/// <summary>Set the Screen Ruler toggle and assert it reached the requested state.</summary>
|
||||
public static void SetAndVerifyScreenRulerToggle(UITestBase testBase, bool enable, string testName)
|
||||
{
|
||||
var toggleSwitch = SetScreenRulerToggle(testBase, enable);
|
||||
Assert.AreEqual(enable, toggleSwitch.IsOn, $"Screen Ruler toggle switch should be {(enable ? "ON" : "OFF")} for {testName}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read the activation shortcut straight from the Settings window's ShortcutControl — the
|
||||
/// EditButton's UIA HelpText, which the control sets to the live shortcut (e.g.
|
||||
/// "Win + Ctrl + Shift + M"). Polls until the window reports a real shortcut (a chord that
|
||||
/// includes a non-modifier key) rather than the "Configure shortcut" placeholder or a transient
|
||||
/// empty value while the page is still binding. Never substitutes a hard-coded default: the test
|
||||
/// must send exactly what the module is bound to, because a wrong/stale default would silently
|
||||
/// fail to activate and mask the real problem.
|
||||
/// </summary>
|
||||
public static Key[] ReadActivationShortcut(UITestBase testBase)
|
||||
{
|
||||
var shortcutCard = testBase.Session.Find<Element>(By.AccessibilityId("Shortcut_ScreenRuler"), 5000);
|
||||
var shortcutButton = shortcutCard.Find<Element>(By.AccessibilityId("EditButton"), 5000);
|
||||
|
||||
string helpText = string.Empty;
|
||||
var deadline = DateTime.UtcNow.AddMilliseconds(5000);
|
||||
do
|
||||
{
|
||||
helpText = shortcutButton.HelpText ?? string.Empty;
|
||||
var keys = ParseShortcutText(helpText);
|
||||
if (HasMainKey(keys))
|
||||
{
|
||||
testBase.TestContext.WriteLine($"Activation shortcut read from Settings: '{helpText}'.");
|
||||
return keys;
|
||||
}
|
||||
|
||||
Thread.Sleep(200);
|
||||
}
|
||||
while (DateTime.UtcNow < deadline);
|
||||
|
||||
Assert.Fail(
|
||||
$"Could not read the Screen Ruler activation shortcut from the Settings window: the " +
|
||||
$"ShortcutControl EditButton HelpText was '{helpText}' (expected a chord such as " +
|
||||
$"'Win + Ctrl + Shift + M'). Refusing to fall back to a hard-coded default.");
|
||||
return Array.Empty<Key>(); // unreachable — Assert.Fail throws.
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse a shortcut string like "Win + Ctrl + Shift + M" into a <see cref="Key"/> chord (note:
|
||||
/// "win" maps to <see cref="Key.LWin"/>). Returns exactly the keys present — NO default
|
||||
/// substitution; the caller decides whether the result is a usable shortcut.
|
||||
/// </summary>
|
||||
public static Key[] ParseShortcutText(string shortcutText)
|
||||
{
|
||||
var keys = new List<Key>();
|
||||
if (string.IsNullOrEmpty(shortcutText))
|
||||
{
|
||||
return keys.ToArray();
|
||||
}
|
||||
|
||||
foreach (var part in shortcutText.Split(ShortcutSeparators, StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var key = ParseKeyToken(part);
|
||||
if (key.HasValue)
|
||||
{
|
||||
keys.Add(key.Value);
|
||||
}
|
||||
}
|
||||
|
||||
return keys.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>Map one display token ("Win"/"Ctrl"/"Shift"/"Alt", a letter, a digit, "F5", "Space"…) to a <see cref="Key"/>.</summary>
|
||||
private static Key? ParseKeyToken(string token)
|
||||
{
|
||||
var t = token.Trim();
|
||||
if (t.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (t.ToLowerInvariant())
|
||||
{
|
||||
case "win":
|
||||
case "windows":
|
||||
return Key.LWin;
|
||||
case "ctrl":
|
||||
case "control":
|
||||
return Key.Ctrl;
|
||||
case "shift":
|
||||
return Key.Shift;
|
||||
case "alt":
|
||||
return Key.Alt;
|
||||
}
|
||||
|
||||
// Single digit 0-9 → enum names Num0..Num9.
|
||||
if (t.Length == 1 && t[0] >= '0' && t[0] <= '9')
|
||||
{
|
||||
return Enum.TryParse<Key>("Num" + t, out var num) ? num : null;
|
||||
}
|
||||
|
||||
// Letters, function keys ("F5") and named keys ("Space"/"Enter"/"Esc"/"Tab"/"Home"…) match the
|
||||
// Key enum names. Require a leading letter so numeric strings aren't cast straight to enum values.
|
||||
if (char.IsLetter(t[0]) && Enum.TryParse<Key>(t, ignoreCase: true, out var k))
|
||||
{
|
||||
return k;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>True when the chord includes a non-modifier (main) key — i.e. a real, activatable shortcut.</summary>
|
||||
private static bool HasMainKey(Key[] keys) =>
|
||||
keys.Any(k => k is not (Key.LWin or Key.Ctrl or Key.Shift or Key.Alt));
|
||||
|
||||
/// <summary>
|
||||
/// True when the Measure Tool UI is up. Uses a Win32 PROCESS check, NOT winappcli's
|
||||
/// <c>list-windows</c>: enumerating the live/frozen overlay's UIA tree costs seconds on CI (and can
|
||||
/// hang). MeasureToolUI exists only while the ruler is open, so process-presence is an accurate,
|
||||
/// instant, hang-free proxy.
|
||||
/// </summary>
|
||||
public static bool IsScreenRulerUIOpen(UITestBase testBase) =>
|
||||
Process.GetProcessesByName(ScreenRulerProcess).Length > 0;
|
||||
|
||||
/// <summary>Poll until the Measure Tool UI reaches the requested presence.</summary>
|
||||
public static bool WaitForScreenRulerUIState(UITestBase testBase, bool shouldBeOpen, int timeoutMs = 5000, int pollingIntervalMs = 100)
|
||||
{
|
||||
var endTime = DateTime.UtcNow.AddMilliseconds(timeoutMs);
|
||||
while (DateTime.UtcNow < endTime)
|
||||
{
|
||||
if (IsScreenRulerUIOpen(testBase) == shouldBeOpen)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
Thread.Sleep(pollingIntervalMs);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static bool WaitForScreenRulerUI(UITestBase testBase, int timeoutMs = 5000) =>
|
||||
WaitForScreenRulerUIState(testBase, shouldBeOpen: true, timeoutMs);
|
||||
|
||||
public static bool WaitForScreenRulerUIToDisappear(UITestBase testBase, int timeoutMs = 5000) =>
|
||||
WaitForScreenRulerUIState(testBase, shouldBeOpen: false, timeoutMs);
|
||||
|
||||
/// <summary>
|
||||
/// Close the Measure Tool UI via Win32 — gracefully (WM_CLOSE to the main window), then kill as a
|
||||
/// last resort. Deliberately avoids winappcli: a process-scoped <see cref="Session.FromProcess"/>,
|
||||
/// the Close-button search, and <c>list-windows</c> all walk the live/frozen overlay's UIA tree,
|
||||
/// which costs 5–30s on CI. The test's assertions have already run by here, so a fast, reliable
|
||||
/// teardown matters more than a UI-driven close.
|
||||
/// </summary>
|
||||
public static void CloseScreenRulerUI(UITestBase testBase)
|
||||
{
|
||||
var procs = Process.GetProcessesByName(ScreenRulerProcess);
|
||||
if (procs.Length == 0)
|
||||
{
|
||||
Log("CloseScreenRulerUI: not running — nothing to close");
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var p in procs)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Graceful first: WM_CLOSE to the main window; if it doesn't exit, kill it.
|
||||
if (p.MainWindowHandle != IntPtr.Zero && p.CloseMainWindow() && p.WaitForExit(2000))
|
||||
{
|
||||
Log($"CloseScreenRulerUI: pid {p.Id} closed via WM_CLOSE");
|
||||
}
|
||||
else if (!p.HasExited)
|
||||
{
|
||||
Log($"CloseScreenRulerUI: pid {p.Id} didn't close on WM_CLOSE; killing it");
|
||||
p.Kill(entireProcessTree: true);
|
||||
p.WaitForExit(2000);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"CloseScreenRulerUI: closing pid {p.Id} failed: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
p.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
Log("CloseScreenRulerUI: done");
|
||||
}
|
||||
|
||||
/// <summary>Clear the clipboard (STA handled inside the helper).</summary>
|
||||
public static void ClearClipboard() => ClipboardHelper.Clear();
|
||||
|
||||
/// <summary>Read the clipboard text.</summary>
|
||||
public static string GetClipboardText() => ClipboardHelper.GetText();
|
||||
|
||||
/// <summary>Validate clipboard content holds a valid spacing measurement for the given tool.</summary>
|
||||
public static bool ValidateSpacingClipboardContent(string clipboardText, string spacingType)
|
||||
{
|
||||
if (string.IsNullOrEmpty(clipboardText))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return spacingType switch
|
||||
{
|
||||
"Spacing" => Regex.IsMatch(clipboardText, @"\d+\s*[x×]\s*\d+"),
|
||||
"Horizontal Spacing" or "Vertical Spacing" => Regex.IsMatch(clipboardText, @"^\d+$"),
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Send the activation chord, retrying until the Measure Tool UI appears. The runner arms its
|
||||
/// keyboard hook asynchronously after the module is enabled, so the first chord can be lost
|
||||
/// (skill Recipe 4). An initial settle gives the just-enabled module time to register its
|
||||
/// hotkey before the first send. Returns true once a Measure Tool window is visible.
|
||||
/// </summary>
|
||||
public static bool SendShortcutUntilVisible(UITestBase testBase, Key[] activationKeys, int attempts = 5, int perAttemptMs = 3000)
|
||||
{
|
||||
// Let the runner finish wiring the global hotkey after the module was just toggled on.
|
||||
Thread.Sleep(1500);
|
||||
|
||||
for (int i = 0; i < attempts; i++)
|
||||
{
|
||||
Log($"SendShortcutUntilVisible: attempt {i + 1}/{attempts} — sending the activation chord");
|
||||
KeyboardHelper.SendKeys(activationKeys);
|
||||
if (WaitForScreenRulerUI(testBase, perAttemptMs))
|
||||
{
|
||||
Log($"SendShortcutUntilVisible: MeasureToolUI process detected on attempt {i + 1}");
|
||||
return true;
|
||||
}
|
||||
|
||||
Log($"SendShortcutUntilVisible: still not visible after attempt {i + 1}");
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Activate Screen Ruler via the shortcut and wait for the toolbar window.</summary>
|
||||
public static Session ActivateScreenRuler(UITestBase testBase, Key[] activationKeys, string testName)
|
||||
{
|
||||
ClearClipboard();
|
||||
|
||||
// Park the cursor on the primary-monitor centre so the Measure Tool initialises tracking at a
|
||||
// predictable on-screen spot before activation (the cursor can otherwise be anywhere).
|
||||
var (cx, cy) = ScreenCenter();
|
||||
MouseHelper.MoveTo(cx, cy);
|
||||
Thread.Sleep(200);
|
||||
|
||||
Log($"ActivateScreenRuler: sending activation chord {string.Join(" + ", activationKeys)}");
|
||||
Assert.IsTrue(
|
||||
SendShortcutUntilVisible(testBase, activationKeys),
|
||||
$"ScreenRulerUI should appear after pressing activation shortcut for {testName}: {string.Join(" + ", activationKeys)}");
|
||||
|
||||
// Process-scoped session so the toolbar buttons resolve regardless of which Measure Tool
|
||||
// window owns them (the winappcli equivalent of the legacy global Find).
|
||||
Log("ActivateScreenRuler: toolbar is up; building the process-scoped session");
|
||||
var ruler = Session.FromProcess(ScreenRulerProcess, PowerToysModule.ScreenRuler, timeoutMS: 5000);
|
||||
Log("ActivateScreenRuler: session ready");
|
||||
return ruler;
|
||||
}
|
||||
|
||||
/// <summary>Run a spacing-tool measurement and validate the clipboard output.</summary>
|
||||
public static void PerformSpacingToolTest(UITestBase testBase, string buttonId, string testName)
|
||||
{
|
||||
var activationKeys = ReadActivationShortcut(testBase);
|
||||
var ruler = ActivateScreenRuler(testBase, activationKeys, testName);
|
||||
|
||||
SelectToolAndVerify(ruler, buttonId, testName);
|
||||
|
||||
var clipboardText = MeasureWithRetry(testName, PerformMeasurementAction);
|
||||
Assert.IsFalse(string.IsNullOrEmpty(clipboardText), $"{testName}: Clipboard should contain measurement data");
|
||||
Assert.IsTrue(
|
||||
ValidateSpacingClipboardContent(clipboardText, testName),
|
||||
$"{testName}: Clipboard should contain valid spacing measurement, but contained: '{clipboardText}'");
|
||||
|
||||
CloseScreenRulerUI(testBase);
|
||||
Assert.IsTrue(
|
||||
WaitForScreenRulerUIToDisappear(testBase, 2000),
|
||||
$"{testName}: ScreenRulerUI should close after calling CloseScreenRulerUI");
|
||||
}
|
||||
|
||||
/// <summary>Run a bounds-tool measurement (drag a 100x100 box) and validate the clipboard output.</summary>
|
||||
public static void PerformBoundsToolTest(UITestBase testBase)
|
||||
{
|
||||
var activationKeys = ReadActivationShortcut(testBase);
|
||||
var ruler = ActivateScreenRuler(testBase, activationKeys, "bounds test");
|
||||
|
||||
SelectToolAndVerify(ruler, BoundsButtonId, "Bounds");
|
||||
|
||||
var clipboardText = MeasureWithRetry("Bounds", PerformBoundsMeasurement);
|
||||
Assert.IsFalse(string.IsNullOrEmpty(clipboardText), "Clipboard should contain measurement data");
|
||||
Assert.IsTrue(
|
||||
clipboardText.Contains("100 × 100") || clipboardText.Contains("100 x 100"),
|
||||
$"Clipboard should contain '100 x 100', but contained: '{clipboardText}'");
|
||||
|
||||
CloseScreenRulerUI(testBase);
|
||||
Assert.IsTrue(
|
||||
WaitForScreenRulerUIToDisappear(testBase, 2000),
|
||||
"ScreenRulerUI should close after calling CloseScreenRulerUI");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Select a toolbar tool with a coordinate-free winappcli UIA invoke, move the cursor onto the
|
||||
/// capture surface (a single tracked move), then CONFIRM the tool engaged by polling for the
|
||||
/// full-screen measurement overlay window (<c>PowerToys.MeasureToolOverlay</c>) — its presence
|
||||
/// means a following drag/click will actually measure. The overlay only shows once the cursor
|
||||
/// leaves the toolbar onto the surface.
|
||||
/// </summary>
|
||||
private static void SelectToolAndVerify(Session ruler, string buttonId, string testName)
|
||||
{
|
||||
Log($"SelectToolAndVerify[{testName}]: UIA invoke of {buttonId}");
|
||||
ruler.Find<Element>(By.AccessibilityId(buttonId), 15000).Click(msPostAction: 300);
|
||||
Log($"SelectToolAndVerify[{testName}]: UIA invoke of {buttonId} successful");
|
||||
|
||||
// Moving the cursor off the toolbar onto the capture surface is what makes the overlay appear.
|
||||
// ActivateScreenRuler parked the cursor at the screen centre, so move to an offset to produce a
|
||||
// real tracked move (moving to the centre would be a no-op). The overlay shows right after the
|
||||
// move, so just settle briefly and confirm once — no need to poll.
|
||||
var (cx, cy) = ScreenCenter();
|
||||
MouseHelper.MoveTo(cx, cy);
|
||||
Log($"SelectToolAndVerify[{testName}]: cursor moved to ({cx},{cy}); settling 500ms before the overlay check");
|
||||
Thread.Sleep(500);
|
||||
|
||||
Log($"SelectToolAndVerify[{testName}]: checking for the measurement overlay");
|
||||
Assert.IsTrue(
|
||||
IsMeasureOverlayPresent(),
|
||||
$"{testName}: the measurement overlay (PowerToys.MeasureToolOverlay) never appeared after the " +
|
||||
"tool invoke — the Measure Tool never entered capture state, so a measurement can't be taken.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when the Measure Tool's full-screen measurement overlay is up. Detection uses the pure
|
||||
/// Win32 <c>EnumWindows</c> API (via <see cref="WindowControl.EnumerateProcessWindows"/>) filtered
|
||||
/// to the <c>PowerToys.MeasureToolUI</c> process, looking for the overlay window
|
||||
/// (class <c>*OverlayWindow</c> / title <c>PowerToys.MeasureToolOverlay</c>). Win32 is used
|
||||
/// deliberately: winappcli's <c>list-windows</c> attaches a UI Automation client and walks the
|
||||
/// overlay's UIA tree, which disturbs the Measure Tool's live screen-capture session and yields an
|
||||
/// empty measurement on the very next click.
|
||||
/// </summary>
|
||||
private static bool IsMeasureOverlayPresent()
|
||||
{
|
||||
var pids = Process.GetProcessesByName(ScreenRulerProcess).Select(p => p.Id).ToList();
|
||||
var windows = WindowControl.EnumerateProcessWindows(pids);
|
||||
var present = windows.Any(w =>
|
||||
w.Title.Contains("MeasureToolOverlay", StringComparison.OrdinalIgnoreCase) ||
|
||||
w.ClassName.Contains("OverlayWindow", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
var summary = windows.Count == 0
|
||||
? "(none)"
|
||||
: string.Join(", ", windows.Select(w => $"'{w.Title}'[{w.ClassName}]"));
|
||||
Log($"IsMeasureOverlayPresent (Win32 EnumWindows): {windows.Count} window(s): {summary} => overlay {(present ? "PRESENT" : "absent")}");
|
||||
return present;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Take a measuring gesture and return the resulting clipboard text, retrying the gesture IN PLACE
|
||||
/// (without closing/reopening the tool) while the clipboard comes back empty. The Measure Tool only
|
||||
/// produces a measurement after its screen-capture and cursor-tracking threads have delivered data
|
||||
/// for the gesture point; the FIRST overlay of each kind pays a one-time cold start (slowest on
|
||||
/// Win10), so the very first gesture can fire before any frame is processed and yield an empty
|
||||
/// clipboard. The gesture itself (cursor move + click/drag) drives those threads, so simply repeating
|
||||
/// it on the SAME overlay succeeds once the pipeline is warm — closing and reopening would reset the
|
||||
/// cold start every time.
|
||||
/// </summary>
|
||||
private static string MeasureWithRetry(string testName, Action gesture, int maxAttempts = 3)
|
||||
{
|
||||
var clipboard = string.Empty;
|
||||
for (var attempt = 1; attempt <= maxAttempts; attempt++)
|
||||
{
|
||||
gesture();
|
||||
clipboard = GetClipboardText();
|
||||
Log($"{testName}: measurement attempt {attempt}/{maxAttempts}; clipboard = '{clipboard}' (length {clipboard.Length})");
|
||||
if (!string.IsNullOrEmpty(clipboard))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (attempt < maxAttempts)
|
||||
{
|
||||
Log($"{testName}: clipboard empty — retrying the measurement in place");
|
||||
Thread.Sleep(1000);
|
||||
}
|
||||
}
|
||||
|
||||
return clipboard;
|
||||
}
|
||||
|
||||
/// <summary>Spacing measuring gesture: move to a point near the centre and left-click to copy the spacing there.</summary>
|
||||
private static void PerformMeasurementAction()
|
||||
{
|
||||
var (cx, cy) = ScreenCenter();
|
||||
|
||||
Log($"PerformMeasurementAction: move to ({cx - 50},{cy - 50}) then left-click to capture spacing");
|
||||
MouseHelper.MoveTo(cx - 50, cy - 50);
|
||||
Thread.Sleep(300);
|
||||
MouseHelper.LeftClick();
|
||||
Thread.Sleep(500);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bounds measuring gesture: drag a 100x100 box from the centre. The drag's button-up is what copies
|
||||
/// the measurement to the clipboard, so no right-click is needed — and we deliberately skip it so a
|
||||
/// retry can re-drag on the SAME overlay (a right-click with no pending selection closes the bounds
|
||||
/// tool). The 99px delta measures 100x100 inclusive on a per-monitor-DPI-aware host (app.manifest).
|
||||
/// </summary>
|
||||
private static void PerformBoundsMeasurement()
|
||||
{
|
||||
var (cx, cy) = ScreenCenter();
|
||||
Log($"PerformBoundsMeasurement: dragging a 100x100 box from ({cx},{cy})");
|
||||
MouseHelper.Drag(cx, cy, cx + 99, cy + 99);
|
||||
Thread.Sleep(400);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Primary-monitor centre in PHYSICAL pixels. Correct only when the test host is per-monitor
|
||||
/// DPI aware (see the project's app.manifest); otherwise the size is virtualized by the display
|
||||
/// scale factor.
|
||||
/// </summary>
|
||||
private static (int X, int Y) ScreenCenter()
|
||||
{
|
||||
var size = System.Windows.Forms.SystemInformation.PrimaryMonitorSize;
|
||||
return (size.Width / 2, size.Height / 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.PowerToys.UITest.Next;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace ScreenRuler.UITests.Next;
|
||||
|
||||
[TestClass]
|
||||
public class TestShortcutActivation : UITestBase
|
||||
{
|
||||
public TestShortcutActivation()
|
||||
: base(PowerToysModule.PowerToysSettings, WindowSize.Large, enableModules: new[] { TestHelper.ModuleSettingsKey })
|
||||
{
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("Activation")]
|
||||
public void TestScreenRulerShortcutActivation()
|
||||
{
|
||||
var activationKeys = TestHelper.InitializeTest(this, "activation test");
|
||||
|
||||
try
|
||||
{
|
||||
// Test 1: pressing the activation shortcut shows the toolbar.
|
||||
Assert.IsTrue(
|
||||
TestHelper.SendShortcutUntilVisible(this, activationKeys),
|
||||
$"ScreenRulerUI should appear after pressing activation shortcut: {string.Join(" + ", activationKeys)}");
|
||||
|
||||
// Test 2: pressing the activation shortcut again hides the toolbar (it's a toggle).
|
||||
KeyboardHelper.SendKeys(activationKeys);
|
||||
Assert.IsTrue(
|
||||
TestHelper.WaitForScreenRulerUIToDisappear(this, 3000),
|
||||
$"ScreenRulerUI should disappear after pressing activation shortcut again: {string.Join(" + ", activationKeys)}");
|
||||
|
||||
// Test 3: while disabled, the shortcut must not activate the utility.
|
||||
// testBase.Session already targets the Settings window, so no re-attach is needed
|
||||
// (winappcli targets by hwnd/process, not foreground).
|
||||
TestHelper.SetAndVerifyScreenRulerToggle(this, enable: false, "disabled state test");
|
||||
KeyboardHelper.SendKeys(activationKeys);
|
||||
Thread.Sleep(1500);
|
||||
Assert.IsFalse(
|
||||
TestHelper.IsScreenRulerUIOpen(this),
|
||||
"ScreenRulerUI should not appear when Screen Ruler is disabled");
|
||||
|
||||
// Test 4: re-enable and confirm the shortcut activates it again.
|
||||
TestHelper.SetAndVerifyScreenRulerToggle(this, enable: true, "re-enabled state test");
|
||||
Assert.IsTrue(
|
||||
TestHelper.SendShortcutUntilVisible(this, activationKeys),
|
||||
$"ScreenRulerUI should appear after re-enabling and pressing activation shortcut: {string.Join(" + ", activationKeys)}");
|
||||
|
||||
// Test 5: the utility can be closed via the cleanup helper.
|
||||
TestHelper.CloseScreenRulerUI(this);
|
||||
Assert.IsTrue(
|
||||
TestHelper.WaitForScreenRulerUIToDisappear(this, 3000),
|
||||
"ScreenRulerUI should close after calling CloseScreenRulerUI");
|
||||
}
|
||||
finally
|
||||
{
|
||||
TestHelper.CleanupTest(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.PowerToys.UITest.Next;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace ScreenRuler.UITests.Next;
|
||||
|
||||
[TestClass]
|
||||
public class TestSpacing : UITestBase
|
||||
{
|
||||
public TestSpacing()
|
||||
: base(PowerToysModule.PowerToysSettings, WindowSize.Large, enableModules: new[] { TestHelper.ModuleSettingsKey })
|
||||
{
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("Spacing")]
|
||||
public void TestScreenRulerSpacingTool()
|
||||
{
|
||||
TestHelper.InitializeTest(this, "spacing test");
|
||||
try
|
||||
{
|
||||
TestHelper.PerformSpacingToolTest(this, TestHelper.SpacingButtonName, "Spacing");
|
||||
}
|
||||
finally
|
||||
{
|
||||
TestHelper.CleanupTest(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.PowerToys.UITest.Next;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace ScreenRuler.UITests.Next;
|
||||
|
||||
[TestClass]
|
||||
public class TestSpacingHorizontal : UITestBase
|
||||
{
|
||||
public TestSpacingHorizontal()
|
||||
: base(PowerToysModule.PowerToysSettings, WindowSize.Large, enableModules: new[] { TestHelper.ModuleSettingsKey })
|
||||
{
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("Spacing")]
|
||||
public void TestScreenRulerHorizontalSpacingTool()
|
||||
{
|
||||
TestHelper.InitializeTest(this, "horizontal spacing test");
|
||||
try
|
||||
{
|
||||
TestHelper.PerformSpacingToolTest(this, TestHelper.HorizontalSpacingButtonName, "Horizontal Spacing");
|
||||
}
|
||||
finally
|
||||
{
|
||||
TestHelper.CleanupTest(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.PowerToys.UITest.Next;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace ScreenRuler.UITests.Next;
|
||||
|
||||
[TestClass]
|
||||
public class TestSpacingVertical : UITestBase
|
||||
{
|
||||
public TestSpacingVertical()
|
||||
: base(PowerToysModule.PowerToysSettings, WindowSize.Large, enableModules: new[] { TestHelper.ModuleSettingsKey })
|
||||
{
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("Spacing")]
|
||||
public void TestScreenRulerVerticalSpacingTool()
|
||||
{
|
||||
TestHelper.InitializeTest(this, "vertical spacing test");
|
||||
try
|
||||
{
|
||||
TestHelper.PerformSpacingToolTest(this, TestHelper.VerticalSpacingButtonName, "Vertical Spacing");
|
||||
}
|
||||
finally
|
||||
{
|
||||
TestHelper.CleanupTest(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<assemblyIdentity version="1.0.0.0" name="ScreenRuler.UITests.Next.app"/>
|
||||
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<!-- Windows 10+ feature support for unpackaged apps. -->
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||
</application>
|
||||
</compatibility>
|
||||
|
||||
<!--
|
||||
Per-monitor (V2) DPI awareness. REQUIRED for coordinate-exact UI tests: without it the test
|
||||
host is DPI-unaware, so SetCursorPos / GetCursorPos coordinates (used by MouseHelper) are
|
||||
virtualized by the display scale factor and no longer match the PHYSICAL pixels winappcli
|
||||
reports. On a 150%-scaled display that turned a 99px drag into a ~149px measurement
|
||||
(Screen Ruler Bounds reported "150 x 149" instead of "100 x 100").
|
||||
-->
|
||||
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<windowsSettings>
|
||||
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
|
||||
</windowsSettings>
|
||||
</application>
|
||||
</assembly>
|
||||
10
src/modules/colorPicker/ColorPicker.UITests/AssemblyInfo.cs
Normal file
10
src/modules/colorPicker/ColorPicker.UITests/AssemblyInfo.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
// UI tests share global desktop state — the same Settings window, the same clipboard, the same
|
||||
// foreground focus. Parallel execution against shared state is a recipe for non-determinism.
|
||||
// MSTest defaults to parallel-by-method inside an assembly; pin to sequential here.
|
||||
[assembly: DoNotParallelize]
|
||||
@@ -0,0 +1,42 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<!-- Look at Directory.Build.props in root for common stuff as well -->
|
||||
<Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" />
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0-windows10.0.26100.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<RootNamespace>Microsoft.ColorPicker.UITests</RootNamespace>
|
||||
<AssemblyName>ColorPicker.UITests</AssemblyName>
|
||||
|
||||
<!--
|
||||
Microsoft.Testing.Platform: same modern runner Directory.Build.props enables for the rest
|
||||
of the repo, so this test class appears in Test Explorer AND can be run via
|
||||
`dotnet test` / `dotnet run` / `vstest.console.exe`.
|
||||
-->
|
||||
<IsTestingPlatformApplication>true</IsTestingPlatformApplication>
|
||||
<EnableMSTestRunner>true</EnableMSTestRunner>
|
||||
<GenerateDocumentationFile>false</GenerateDocumentationFile>
|
||||
|
||||
<!-- UI tests need a live desktop; never run them as part of MSBuild. -->
|
||||
<RunVSTest>false</RunVSTest>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Stage the built test app under <Platform>\<Configuration>\tests\ so the UI-tests build
|
||||
pipeline (CopyFiles glob **/<plat>/<config>/tests/**) picks it up, matching the other
|
||||
*.UITests projects. Without this it builds to bin\ and is never staged into the artifact. -->
|
||||
<PropertyGroup>
|
||||
<OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\tests\ColorPicker.UITests\</OutputPath>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MSTest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\common\UITestAutomation.Next\UITestAutomation.Next.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,446 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using Microsoft.PowerToys.UITest.Next;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace Microsoft.ColorPicker.UITests;
|
||||
|
||||
/// <summary>
|
||||
/// Full end-to-end Color Picker scenario, driven entirely through the Settings UI:
|
||||
/// 1. From the Settings app, navigate to the Color Picker page via the utilities stack.
|
||||
/// 2. On the page, toggle the module OFF and verify <c>PowerToys.ColorPickerUI</c> exits.
|
||||
/// 3. Toggle it back ON and verify <c>PowerToys.ColorPickerUI</c> respawns.
|
||||
/// 4. Read the activation shortcut from the page's <c>ShortcutControl</c> (the EditButton
|
||||
/// exposes <c>HotkeySettings.ToString()</c> via <c>AutomationProperties.HelpText</c>).
|
||||
/// 5. Clear the clipboard, move the cursor, send the shortcut chord.
|
||||
/// 6. Wait for the picker overlay window and read the displayed HEX from the overlay's
|
||||
/// automation-peer TextBlock (AutomationId="ColorHexAutomationPeer").
|
||||
/// 7. Left-click to capture. ColorPicker writes the captured color to the clipboard.
|
||||
/// 8. Read the captured value from the clipboard and assert it matches the overlay HEX.
|
||||
/// 9. Wait for the editor window and assert the captured value appears in its tree.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The overlay's visible ColorTextBlock has <c>AutomationProperties.Name="{Binding ColorName}"</c>
|
||||
/// so UIA exposes the friendly color name (e.g. "White"), not the HEX. To work around that,
|
||||
/// MainView.xaml carries a hidden sibling TextBlock bound to <c>ColorText</c> with
|
||||
/// <c>AutomationId="ColorHexAutomationPeer"</c> — a test-only UIA hook that lets us read the
|
||||
/// actually-displayed HEX value without affecting the visual layout or accessibility UX.
|
||||
/// </remarks>
|
||||
[TestClass]
|
||||
public class ColorPickerEndToEndTests : UITestBase
|
||||
{
|
||||
public ColorPickerEndToEndTests()
|
||||
: base(PowerToysModule.PowerToysSettings, enableModules: new[] { "ColorPicker" })
|
||||
{
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("ColorPicker")]
|
||||
[TestCategory("winappcli-POC")]
|
||||
public void NavigateReadShortcutActivateAndCapture()
|
||||
{
|
||||
try
|
||||
{
|
||||
RunTest();
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Universal cleanup: close any leftover ColorPicker window (overlay or editor),
|
||||
// then close the Settings window. Tolerant — never throws so it can't mask the
|
||||
// real test failure.
|
||||
WindowControl.TryCloseByApp("PowerToys.ColorPickerUI");
|
||||
WindowControl.TryCloseByApp("PowerToys.Settings");
|
||||
}
|
||||
}
|
||||
|
||||
private void RunTest()
|
||||
{
|
||||
// -- 1. Navigate via the utilities stack on the right of the dashboard ----------------
|
||||
// The Dashboard's right-side ModuleList renders each utility as a clickable SettingsCard
|
||||
// whose header is a TextBlock with the module's Label (e.g. "Color Picker"). The
|
||||
// SettingsCard itself isn't surfaced by name "Color Picker" in winappcli's search — only
|
||||
// its inner TextBlock label is — and the TextBlock has no InvokePattern (the click is
|
||||
// handled by the SettingsCard's OnSettingsCardClick).
|
||||
//
|
||||
// A "Color Picker" search returns 4 elements: the Quick-Access tile (Button) and its
|
||||
// label (TextBlock with invokableAncestor) on the left, plus the utility-stack label
|
||||
// (TextBlock) and ToggleSwitch on the right. We pick the rightmost TextBlock (largest
|
||||
// X coordinate) — that's the utility-stack label — and mouse-click it (winapp ui click
|
||||
// uses real mouse simulation, which triggers the ancestor SettingsCard's click).
|
||||
var matches = Session.FindAll<Element>(By.Name("Color Picker"));
|
||||
TestContext.WriteLine($"'Color Picker' search returned {matches.Count} elements:");
|
||||
foreach (var m in matches)
|
||||
{
|
||||
TestContext.WriteLine($" [{m.ControlType,-10}] class='{m.ClassName}' at ({m.X},{m.Y}) {m.Width}x{m.Height} sel='{m.Selector}'");
|
||||
}
|
||||
|
||||
var utilityItem = matches
|
||||
.Where(m => m.ClassName.Equals("TextBlock", StringComparison.OrdinalIgnoreCase))
|
||||
.OrderByDescending(m => m.X)
|
||||
.FirstOrDefault();
|
||||
Assert.IsNotNull(
|
||||
utilityItem,
|
||||
"Could not find a 'Color Picker' TextBlock to click. Is the dashboard visible? See element dump above.");
|
||||
TestContext.WriteLine($"Clicking utility-stack 'Color Picker' TextBlock at x={utilityItem!.X}, y={utilityItem.Y}");
|
||||
utilityItem.MouseClick(msPostAction: 800);
|
||||
TestContext.WriteLine("Navigated to Color Picker page (clicked utility-stack item).");
|
||||
|
||||
// -- 2. Find the page-level enable toggle ---------------------------------------------
|
||||
// After navigation, the dashboard is gone and the page's enable toggle is the only
|
||||
// "Color Picker" ToggleSwitch in the tree. The ToggleSwitch wrapper pins
|
||||
// ClassName="ToggleSwitch" so the search is unambiguous.
|
||||
var toggle = Find<ToggleSwitch>(By.Name("Color Picker"));
|
||||
var initialIsOn = toggle.IsOn;
|
||||
TestContext.WriteLine($"Initial toggle state: IsOn={initialIsOn}");
|
||||
|
||||
try
|
||||
{
|
||||
// -- 3. Toggle the module OFF and verify the runner terminates ColorPickerUI -----
|
||||
// If currently OFF, prime ON first so OFF→ON→OFF gives us a real lifecycle signal.
|
||||
if (!toggle.IsOn)
|
||||
{
|
||||
toggle.Toggle(true);
|
||||
Assert.IsTrue(
|
||||
toggle.WaitForProperty("ToggleState", "On", timeoutMS: 5_000),
|
||||
"Priming: toggle UI did not flip to On.");
|
||||
Assert.IsTrue(
|
||||
WaitForProcess("PowerToys.ColorPickerUI", expected: true, timeoutMS: 10_000),
|
||||
"Priming: PowerToys.ColorPickerUI did not start after enabling.");
|
||||
}
|
||||
|
||||
toggle.Toggle(false);
|
||||
Assert.IsTrue(
|
||||
toggle.WaitForProperty("ToggleState", "Off", timeoutMS: 5_000),
|
||||
"Toggle UI did not flip to Off.");
|
||||
Assert.IsTrue(
|
||||
WaitForProcess("PowerToys.ColorPickerUI", expected: false, timeoutMS: 10_000),
|
||||
"PowerToys.ColorPickerUI did not exit within 10s after toggling module OFF.");
|
||||
TestContext.WriteLine("Toggled OFF; ColorPickerUI process exited.");
|
||||
|
||||
// -- 4. Toggle the module ON and verify the runner respawns ColorPickerUI -------
|
||||
toggle.Toggle(true);
|
||||
Assert.IsTrue(
|
||||
toggle.WaitForProperty("ToggleState", "On", timeoutMS: 5_000),
|
||||
"Toggle UI did not flip to On.");
|
||||
Assert.IsTrue(
|
||||
WaitForProcess("PowerToys.ColorPickerUI", expected: true, timeoutMS: 10_000),
|
||||
"PowerToys.ColorPickerUI did not start within 10s after toggling module ON.");
|
||||
TestContext.WriteLine("Toggled ON; ColorPickerUI process running.");
|
||||
|
||||
// -- 5. Read the activation shortcut from the UI --------------------------------
|
||||
// ShortcutControl renders the current shortcut on an inner Button (x:Name="EditButton")
|
||||
// whose AutomationProperties.HelpText is set to HotkeySettings.ToString() (e.g.
|
||||
// "Win + Shift + C"). x:Name reflects as the UIA AutomationId in WinUI when no
|
||||
// explicit AutomationId is set, so we look it up by that.
|
||||
var editButton = Find<Button>(By.AccessibilityId("EditButton"));
|
||||
var shortcutText = editButton.HelpText;
|
||||
TestContext.WriteLine($"Activation shortcut (from EditButton HelpText): '{shortcutText}'");
|
||||
Assert.IsFalse(
|
||||
string.IsNullOrWhiteSpace(shortcutText),
|
||||
"Could not read activation shortcut HelpText from the ShortcutControl EditButton.");
|
||||
|
||||
var keys = ParseShortcutText(shortcutText);
|
||||
Assert.IsTrue(
|
||||
keys.Length > 0,
|
||||
$"Could not parse any keys from shortcut text '{shortcutText}'.");
|
||||
TestContext.WriteLine($"Parsed key chord: [{string.Join(", ", keys)}]");
|
||||
|
||||
// -- 6. Clear the clipboard and park the cursor ---------------------------------
|
||||
// ClipboardHelper.Clear runs the Clipboard call on an STA thread (required by
|
||||
// System.Windows.Forms.Clipboard) and swallows any contention errors.
|
||||
var seedClipboard = ClipboardHelper.GetText();
|
||||
ClipboardHelper.Clear();
|
||||
TestContext.WriteLine($"Cleared clipboard. (Previous content was {seedClipboard.Length} chars.)");
|
||||
|
||||
var screen = System.Windows.Forms.SystemInformation.PrimaryMonitorSize;
|
||||
int cx = screen.Width / 2;
|
||||
int cy = screen.Height / 2;
|
||||
MouseHelper.MoveTo(cx, cy);
|
||||
TestContext.WriteLine($"Cursor parked at ({cx}, {cy}) — primary screen center.");
|
||||
|
||||
// -- 7+8. Activate via the shortcut, then wait for the picker overlay ------------
|
||||
// The overlay (ColorPickerUI's MainWindow) is a small, LAYERED, transparent, topmost,
|
||||
// no-taskbar window. Once activated it stays visible and follows the cursor until a
|
||||
// click/Esc, so normally it shows immediately and winapp enumerates it WITHOUT any
|
||||
// cursor movement (the common path locally and on most agents). Intermittently on a
|
||||
// slow/loaded agent, winapp lists ZERO ColorPickerUI windows even though the runner
|
||||
// logged the hotkey firing and ColorPicker activated — i.e. the overlay never reached a
|
||||
// UIA-visible, on-screen state. Two mitigations, applied per attempt:
|
||||
// * Poll patiently BEFORE re-sending: re-issuing the chord runs
|
||||
// StartUserSession -> EndUserSession, which HIDES then re-shows the overlay, so the
|
||||
// old 2s re-send cadence churned the window and a slow winapp poll kept missing it.
|
||||
// * Reposition the cursor once mid-wait: ColorPicker re-positions + re-renders the
|
||||
// overlay at the live cursor, recovering it if it landed off-screen/uncomposited.
|
||||
// We still retry because the very first chord can be lost if the runner hasn't finished
|
||||
// arming its WH_KEYBOARD_LL hook. The overlay is ~120x64 (vs the ~660x570 editor), so
|
||||
// filter by size; the cursor settles on a stable pixel for the later HEX read + click.
|
||||
const int activationAttempts = 3;
|
||||
Session? overlay = null;
|
||||
for (int attempt = 1; attempt <= activationAttempts && overlay is null; attempt++)
|
||||
{
|
||||
TestContext.WriteLine($"Sending activation chord [{string.Join(", ", keys)}] (attempt {attempt}/{activationAttempts}).");
|
||||
KeyboardHelper.SendKeys(keys);
|
||||
|
||||
overlay = WindowsFinder.WaitForWindowByApp(
|
||||
"PowerToys.ColorPickerUI", w => w.Width < 300 && w.Height < 200, timeoutMS: 2_500);
|
||||
|
||||
if (overlay is null)
|
||||
{
|
||||
// Recovery kick: nudge the overlay to a fresh on-screen spot, then keep polling
|
||||
// before re-sending (which would hide/re-show it and restart the churn).
|
||||
MouseHelper.MoveTo(cx + 60, cy + 60);
|
||||
overlay = WindowsFinder.WaitForWindowByApp(
|
||||
"PowerToys.ColorPickerUI", w => w.Width < 300 && w.Height < 200, timeoutMS: 2_500);
|
||||
}
|
||||
}
|
||||
|
||||
if (overlay is null)
|
||||
{
|
||||
var dump = string.Join(
|
||||
Environment.NewLine,
|
||||
WindowsFinder.ListByApp("PowerToys.ColorPickerUI")
|
||||
.Select(w => $" hwnd={w.Hwnd} title='{w.Title}' class='{w.ClassName}' size={w.Width}x{w.Height}"));
|
||||
Assert.Fail(
|
||||
$"Picker overlay did not appear after {activationAttempts} shortcut attempts." + Environment.NewLine +
|
||||
" The hotkey DID reach the runner (it logs 'ColorPicker hotkey is invoked') and ColorPicker" + Environment.NewLine +
|
||||
" activated, so this is the overlay (a small layered/transparent/topmost window) failing to" + Environment.NewLine +
|
||||
" become UIA-visible/on-screen on this agent — a rendering/enumeration issue, not input." + Environment.NewLine +
|
||||
" Current ColorPickerUI windows:" + Environment.NewLine +
|
||||
(dump.Length > 0 ? dump : " (none)"));
|
||||
}
|
||||
|
||||
TestContext.WriteLine($"Picker overlay appeared: hwnd={overlay!.WindowHandle}");
|
||||
|
||||
// -- 9. Read the displayed HEX from the overlay's automation-peer TextBlock -----
|
||||
// The peer is a Visibility=Visible, Opacity=0 TextBlock added to MainView.xaml
|
||||
// specifically so UIA-driven tests can read the live HEX value. It is bound to
|
||||
// the same `ColorText` source as the visible TextBlock, so it always matches
|
||||
// what the user sees.
|
||||
string overlayHex = string.Empty;
|
||||
try
|
||||
{
|
||||
var peer = overlay.Find(By.AccessibilityId("ColorHexAutomationPeer"), timeoutMS: 2_000);
|
||||
overlayHex = peer.Name;
|
||||
TestContext.WriteLine($"Overlay HEX (from automation peer): '{overlayHex}'");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
TestContext.WriteLine($"Could not read ColorHexAutomationPeer: {ex.Message}");
|
||||
}
|
||||
|
||||
Assert.IsFalse(
|
||||
string.IsNullOrEmpty(overlayHex),
|
||||
"Failed to read the overlay's HEX value from the ColorHexAutomationPeer TextBlock.");
|
||||
|
||||
// -- 10. Click to capture; ColorPicker writes the configured format to clipboard
|
||||
MouseHelper.LeftClick();
|
||||
TestContext.WriteLine("Sent left-click to capture color.");
|
||||
|
||||
var capturedColor = ClipboardHelper.WaitForText(ignoredValue: string.Empty, timeoutMS: 3_000);
|
||||
Assert.IsFalse(
|
||||
string.IsNullOrEmpty(capturedColor),
|
||||
"Nothing was written to the clipboard within 3s after the click. " +
|
||||
"Did the picker actually capture? (Check that left-click is mapped to a 'PickColor' action.)");
|
||||
TestContext.WriteLine($"Captured color (clipboard): '{capturedColor}'");
|
||||
|
||||
// Cross-check: the clipboard value should be the same HEX the overlay was showing.
|
||||
// Both come from `ColorText` in MainViewModel, just routed differently (overlay
|
||||
// binding vs. ColorPickerHelper.CopyToClipboard on Picker_MouseDown).
|
||||
Assert.IsTrue(
|
||||
ContainsIgnoringHash(capturedColor, overlayHex) || ContainsIgnoringHash(overlayHex, capturedColor),
|
||||
$"Overlay HEX '{overlayHex}' and clipboard '{capturedColor}' don't match.");
|
||||
TestContext.WriteLine("Overlay HEX matches clipboard value.");
|
||||
|
||||
// -- 11. Wait for the editor window ---------------------------------------------
|
||||
var editor = WindowsFinder.WaitForWindowByApp(
|
||||
"PowerToys.ColorPickerUI",
|
||||
w => w.Width > 300 && w.Height > 300,
|
||||
timeoutMS: 10_000);
|
||||
|
||||
if (editor is null)
|
||||
{
|
||||
var dump = string.Join(
|
||||
Environment.NewLine,
|
||||
WindowsFinder.ListByApp("PowerToys.ColorPickerUI")
|
||||
.Select(w => $" hwnd={w.Hwnd} title='{w.Title}' class='{w.ClassName}' size={w.Width}x{w.Height}"));
|
||||
Assert.Fail(
|
||||
"ColorPicker editor window did not appear within 10s after the click." + Environment.NewLine +
|
||||
" Current ColorPickerUI windows:" + Environment.NewLine +
|
||||
(dump.Length > 0 ? dump : " (none)"));
|
||||
}
|
||||
|
||||
TestContext.WriteLine($"Editor window: hwnd={editor!.WindowHandle} title='{editor.WindowTitle}'");
|
||||
|
||||
// -- 12. Find the captured color inside the editor's tree ------------------------
|
||||
// From ColorEditorView.xaml the format list is populated from `ColorRepresentations`.
|
||||
// Each format renders as a ColorFormatControl (DataItem in the UIA tree) that
|
||||
// contains a TextBox holding the formatted color string. The captured clipboard
|
||||
// value will be ONE of those formats — we just need to find any element whose Name
|
||||
// or Value contains it.
|
||||
var tree = editor.Inspect(depth: 12);
|
||||
var values = new List<(string Type, string Name, string Value)>();
|
||||
WalkElements(tree, values);
|
||||
|
||||
TestContext.WriteLine($"Editor exposed {values.Count} elements. First 40:");
|
||||
foreach (var v in values.Take(40))
|
||||
{
|
||||
TestContext.WriteLine($" [{v.Type,-12}] name='{v.Name}' value='{v.Value}'");
|
||||
}
|
||||
|
||||
Assert.IsTrue(values.Count > 0, "Editor reported no readable elements via inspect --json.");
|
||||
|
||||
// Match: find any element whose Name or Value contains the clipboard text
|
||||
// case-insensitively. If the clipboard had a '#' prefix (e.g. "#FFFFFF") and the
|
||||
// editor renders without it, also try the bare-hex form.
|
||||
var needle = capturedColor.Trim();
|
||||
var needleBareHex = needle.TrimStart('#');
|
||||
|
||||
var match = values.FirstOrDefault(v =>
|
||||
v.Name.Contains(needle, StringComparison.OrdinalIgnoreCase) ||
|
||||
v.Value.Contains(needle, StringComparison.OrdinalIgnoreCase) ||
|
||||
(needleBareHex.Length > 0 &&
|
||||
(v.Name.Contains(needleBareHex, StringComparison.OrdinalIgnoreCase) ||
|
||||
v.Value.Contains(needleBareHex, StringComparison.OrdinalIgnoreCase))));
|
||||
|
||||
if (string.IsNullOrEmpty(match.Name) && string.IsNullOrEmpty(match.Value))
|
||||
{
|
||||
Assert.Fail(
|
||||
$"Captured color '{capturedColor}' not found in editor tree." + Environment.NewLine +
|
||||
" See element dump above.");
|
||||
}
|
||||
|
||||
TestContext.WriteLine(
|
||||
$"MATCH: captured '{capturedColor}' found in editor element [{match.Type}] Name='{match.Name}' Value='{match.Value}'");
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Restore the toggle to its initial state regardless of pass/fail. Best-effort so
|
||||
// a cleanup failure can't mask the real test failure.
|
||||
try
|
||||
{
|
||||
if (toggle.IsOn != initialIsOn)
|
||||
{
|
||||
toggle.Toggle(initialIsOn);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Case-insensitive substring comparison that ignores a leading <c>#</c> on either side.
|
||||
/// Used to cross-check the overlay HEX against the clipboard value when only one of them
|
||||
/// carries the prefix.
|
||||
/// </summary>
|
||||
private static bool ContainsIgnoringHash(string haystack, string needle)
|
||||
{
|
||||
if (string.IsNullOrEmpty(haystack) || string.IsNullOrEmpty(needle))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return haystack.TrimStart('#').Contains(needle.TrimStart('#'), StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse a UI-rendered shortcut string like <c>"Win + Shift + C"</c> into the
|
||||
/// <see cref="Key"/> sequence the harness's keyboard helper expects. Matches the parser
|
||||
/// pattern used by <c>ScreenRuler.UITests/TestHelper.cs</c>.
|
||||
/// </summary>
|
||||
private static Key[] ParseShortcutText(string shortcutText)
|
||||
{
|
||||
var separators = new[] { " + ", "+", " " };
|
||||
var parts = shortcutText.Split(separators, StringSplitOptions.RemoveEmptyEntries);
|
||||
var keys = new List<Key>();
|
||||
|
||||
foreach (var raw in parts)
|
||||
{
|
||||
var part = raw.Trim().ToLowerInvariant();
|
||||
Key? key = part switch
|
||||
{
|
||||
"win" or "windows" => Key.LWin,
|
||||
"ctrl" or "control" => Key.Ctrl,
|
||||
"shift" => Key.Shift,
|
||||
"alt" => Key.Alt,
|
||||
_ when part.Length == 1 && part[0] >= 'a' && part[0] <= 'z' =>
|
||||
(Key)Enum.Parse(typeof(Key), part.ToUpperInvariant()),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
if (key.HasValue)
|
||||
{
|
||||
keys.Add(key.Value);
|
||||
}
|
||||
}
|
||||
|
||||
return keys.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>Poll <see cref="Process.GetProcessesByName"/> until presence matches <paramref name="expected"/>.</summary>
|
||||
private static bool WaitForProcess(string name, bool expected, int timeoutMS)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + TimeSpan.FromMilliseconds(timeoutMS);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
var running = Process.GetProcessesByName(name).Length > 0;
|
||||
if (running == expected)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
Thread.Sleep(250);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Walk the nested <c>inspect --json</c> tree and collect every element with a non-empty
|
||||
/// name or value. Output shape (from winappcli):
|
||||
/// <c>{ "windows": [{ "elements": [{ "type", "name", "value", "children": [...] }] }] }</c>.
|
||||
/// </summary>
|
||||
private static void WalkElements(JsonElement root, List<(string Type, string Name, string Value)> sink)
|
||||
{
|
||||
if (!root.TryGetProperty("windows", out var windows) || windows.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var w in windows.EnumerateArray())
|
||||
{
|
||||
if (w.TryGetProperty("elements", out var els) && els.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var el in els.EnumerateArray())
|
||||
{
|
||||
Walk(el, sink);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void Walk(JsonElement el, List<(string Type, string Name, string Value)> sink)
|
||||
{
|
||||
var type = el.TryGetProperty("type", out var t) ? (t.GetString() ?? string.Empty) : string.Empty;
|
||||
var name = el.TryGetProperty("name", out var n) ? (n.GetString() ?? string.Empty) : string.Empty;
|
||||
var value = el.TryGetProperty("value", out var v) ? (v.GetString() ?? string.Empty) : string.Empty;
|
||||
|
||||
if (!string.IsNullOrEmpty(name) || !string.IsNullOrEmpty(value))
|
||||
{
|
||||
sink.Add((type, name, value));
|
||||
}
|
||||
|
||||
if (el.TryGetProperty("children", out var ch) && ch.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var c in ch.EnumerateArray())
|
||||
{
|
||||
Walk(c, sink);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,20 @@
|
||||
</Border.Effect>-->
|
||||
|
||||
<Grid>
|
||||
<!--
|
||||
UIA test hook. Mirrors the displayed HEX value (ColorText) into a
|
||||
transparent TextBlock with a stable AutomationId so automated UI tests
|
||||
can read the picker's current color without depending on the visible
|
||||
TextBlock, whose AutomationProperties.Name is bound to ColorName (the
|
||||
friendly name) for screen-reader UX and therefore masks the HEX from UIA.
|
||||
-->
|
||||
<TextBlock
|
||||
x:Name="ColorHexAutomationPeer"
|
||||
AutomationProperties.AutomationId="ColorHexAutomationPeer"
|
||||
IsHitTestVisible="False"
|
||||
Opacity="0"
|
||||
Text="{Binding ColorText}" />
|
||||
|
||||
<!-- only color format - one line -->
|
||||
<Grid Margin="2" Visibility="{Binding ShowColorName, Converter={StaticResource bool2InvertedVisibilityConverter}}">
|
||||
<Grid.ColumnDefinitions>
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.PowerToys.UITest;
|
||||
|
||||
namespace Microsoft.ColorPicker.UITests
|
||||
{
|
||||
public class ColorPickerUITest : UITestBase
|
||||
{
|
||||
public ColorPickerUITest()
|
||||
: base(PowerToysModule.Runner)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<!-- Look at Directory.Build.props in root for common stuff as well -->
|
||||
<Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" />
|
||||
|
||||
<PropertyGroup>
|
||||
<ProjectGuid>{6880CE86-5B71-4440-9795-79A325F95747}</ProjectGuid>
|
||||
<RootNamespace>Microsoft.ColorPicker.UITests</RootNamespace>
|
||||
<IsPackable>false</IsPackable>
|
||||
<Nullable>enable</Nullable>
|
||||
<OutputType>Library</OutputType>
|
||||
|
||||
<!-- This is a UI test, so don't run as part of MSBuild -->
|
||||
<RunVSTest>false</RunVSTest>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\tests\UITests-ColorPicker\</OutputPath>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Appium.WebDriver" />
|
||||
<PackageReference Include="MSTest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\common\UITestAutomation\UITestAutomation.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
42
src/settings-ui/Settings.UITests/Settings.UITests.csproj
Normal file
42
src/settings-ui/Settings.UITests/Settings.UITests.csproj
Normal file
@@ -0,0 +1,42 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<!-- Look at Directory.Build.props in root for common stuff as well -->
|
||||
<Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" />
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0-windows10.0.26100.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<RootNamespace>Microsoft.Settings.UITests</RootNamespace>
|
||||
<AssemblyName>Settings.UITests</AssemblyName>
|
||||
|
||||
<!--
|
||||
Microsoft.Testing.Platform: same modern runner Directory.Build.props enables for the rest
|
||||
of the repo, so this test class appears in Test Explorer AND can be run via
|
||||
`dotnet test` / `dotnet run` / `vstest.console.exe`.
|
||||
-->
|
||||
<IsTestingPlatformApplication>true</IsTestingPlatformApplication>
|
||||
<EnableMSTestRunner>true</EnableMSTestRunner>
|
||||
<GenerateDocumentationFile>false</GenerateDocumentationFile>
|
||||
|
||||
<!-- UI tests need a live desktop; never run them as part of MSBuild. -->
|
||||
<RunVSTest>false</RunVSTest>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Stage the built test app under <Platform>\<Configuration>\tests\ so the UI-tests build
|
||||
pipeline (CopyFiles glob **/<plat>/<config>/tests/**) picks it up, matching the other
|
||||
*.UITests projects. Without this it builds to bin\ and is never staged into the artifact. -->
|
||||
<PropertyGroup>
|
||||
<OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\tests\Settings.UITests\</OutputPath>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MSTest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\common\UITestAutomation.Next\UITestAutomation.Next.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
153
src/settings-ui/Settings.UITests/SettingsNavigationSmokeTests.cs
Normal file
153
src/settings-ui/Settings.UITests/SettingsNavigationSmokeTests.cs
Normal file
@@ -0,0 +1,153 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Reflection;
|
||||
using Microsoft.PowerToys.UITest.Next;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace Microsoft.Settings.UITests;
|
||||
|
||||
/// <summary>
|
||||
/// Smoke test that drives the Settings shell via winappcli and asserts that clicking every
|
||||
/// <c>NavigationViewItem</c> leaves the process alive.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Inspired by <see href="https://github.com/microsoft/PowerToys/pull/48414"/>. Uses our
|
||||
/// <see cref="UITestAutomation.Next"/> harness instead of the PR's bare wrapper so the same
|
||||
/// surface (Find/Click/By/Element) works across all module tests.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Inherits <see cref="UITestBase"/> with <see cref="UITestBase.ReuseScopeAcrossTests"/> on, so a
|
||||
/// single Settings window is reused across every nav-item case (one launch per class, not per test)
|
||||
/// while still getting the framework's unified failure-media capture for free — no test-local
|
||||
/// screenshot code. One method per nav item via <c>[DynamicData]</c> gives a discrete pass/fail per
|
||||
/// item in Test Explorer / pipeline reports — if <c>FancyZonesNavItem</c> regresses, the report names it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Selectors are AutomationIds straight from
|
||||
/// <c>src/settings-ui/Settings.UI/SettingsXAML/Views/ShellPage.xaml</c>; they don't change with
|
||||
/// the user's MUI language so the test stays localization-independent. Parent groups
|
||||
/// (<c>SystemToolsNavItem</c>, <c>WindowingAndLayoutsNavItem</c>, <c>InputOutputNavItem</c>,
|
||||
/// <c>FileManagementNavItem</c>, <c>AdvancedNavItem</c>) have <c>SelectsOnInvoked="False"</c>
|
||||
/// and only expand on invoke; our <see cref="Element.Click"/> tries InvokePattern \u2192
|
||||
/// TogglePattern \u2192 SelectionItemPattern \u2192 ExpandCollapsePattern in order so the same
|
||||
/// call works for both navigation-y leaves and expand-y groups.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[TestClass]
|
||||
public sealed class SettingsNavigationSmokeTests : UITestBase
|
||||
{
|
||||
// (ParentGroupSlug | null, NavItemSlug). Mirrors the live hierarchy in ShellPage.xaml.
|
||||
// Footer items (OOBE/WhatIsNew/Feedback/Close) are intentionally excluded \u2014 those use
|
||||
// Tapped handlers that open dialogs / external pages and aren't part of the in-shell
|
||||
// navigation surface we're guarding against FailFast.
|
||||
private static readonly NavigationCase[] NavigationItems = new[]
|
||||
{
|
||||
// Top-level
|
||||
new NavigationCase(null, "DashboardNavItem"),
|
||||
new NavigationCase(null, "GeneralNavItem"),
|
||||
|
||||
// System tools
|
||||
new NavigationCase("SystemToolsNavItem", "AdvancedPasteNavItem"),
|
||||
new NavigationCase("SystemToolsNavItem", "AwakeNavItem"),
|
||||
new NavigationCase("SystemToolsNavItem", "CmdPalNavItem"),
|
||||
new NavigationCase("SystemToolsNavItem", "ColorPickerNavItem"),
|
||||
new NavigationCase("SystemToolsNavItem", "LightSwitchNavItem"),
|
||||
new NavigationCase("SystemToolsNavItem", "PowerLauncherNavItem"),
|
||||
new NavigationCase("SystemToolsNavItem", "ScreenRulerNavItem"),
|
||||
new NavigationCase("SystemToolsNavItem", "ShortcutGuideNavItem"),
|
||||
new NavigationCase("SystemToolsNavItem", "TextExtractorNavItem"),
|
||||
new NavigationCase("SystemToolsNavItem", "ZoomItNavItem"),
|
||||
|
||||
// Windowing and layouts
|
||||
new NavigationCase("WindowingAndLayoutsNavItem", "AlwaysOnTopNavItem"),
|
||||
new NavigationCase("WindowingAndLayoutsNavItem", "CropAndLockNavItem"),
|
||||
new NavigationCase("WindowingAndLayoutsNavItem", "FancyZonesNavItem"),
|
||||
new NavigationCase("WindowingAndLayoutsNavItem", "GrabAndMoveNavItem"),
|
||||
new NavigationCase("WindowingAndLayoutsNavItem", "WorkspacesNavItem"),
|
||||
|
||||
// Input / Output
|
||||
new NavigationCase("InputOutputNavItem", "KeyboardManagerNavItem"),
|
||||
new NavigationCase("InputOutputNavItem", "MouseUtilitiesNavItem"),
|
||||
new NavigationCase("InputOutputNavItem", "MouseWithoutBordersNavItem"),
|
||||
new NavigationCase("InputOutputNavItem", "PowerDisplayNavItem"),
|
||||
new NavigationCase("InputOutputNavItem", "QuickAccentNavItem"),
|
||||
|
||||
// File management
|
||||
new NavigationCase("FileManagementNavItem", "PowerPreviewNavItem"),
|
||||
new NavigationCase("FileManagementNavItem", "FileLocksmithNavItem"),
|
||||
new NavigationCase("FileManagementNavItem", "ImageResizerNavItem"),
|
||||
new NavigationCase("FileManagementNavItem", "NewPlusNavItem"),
|
||||
new NavigationCase("FileManagementNavItem", "PeekNavItem"),
|
||||
new NavigationCase("FileManagementNavItem", "PowerRenameNavItem"),
|
||||
|
||||
// Advanced
|
||||
new NavigationCase("AdvancedNavItem", "CmdNotFoundNavItem"),
|
||||
new NavigationCase("AdvancedNavItem", "EnvironmentVariablesNavItem"),
|
||||
new NavigationCase("AdvancedNavItem", "HostsNavItem"),
|
||||
new NavigationCase("AdvancedNavItem", "RegistryPreviewNavItem"),
|
||||
};
|
||||
|
||||
private const string ScopeProcessName = "PowerToys.Settings";
|
||||
private const PowerToysModule Scope = PowerToysModule.PowerToysSettings;
|
||||
|
||||
public SettingsNavigationSmokeTests()
|
||||
: base(Scope)
|
||||
{
|
||||
}
|
||||
|
||||
// Reuse one Settings window across all nav-item cases (no per-test relaunch); the framework
|
||||
// still captures failure media per test and stops Settings once the class finishes.
|
||||
protected override bool ReuseScopeAcrossTests => true;
|
||||
|
||||
public static IEnumerable<object[]> NavigationCases()
|
||||
{
|
||||
foreach (var c in NavigationItems)
|
||||
{
|
||||
yield return new object[] { c.ParentGroupSlug ?? string.Empty, c.NavItemSlug };
|
||||
}
|
||||
}
|
||||
|
||||
public static string GetNavCaseDisplayName(MethodInfo _, object[] data)
|
||||
{
|
||||
var parent = (string)data[0];
|
||||
var item = (string)data[1];
|
||||
return string.IsNullOrEmpty(parent) ? item : $"{parent} -> {item}";
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("Settings")]
|
||||
[TestCategory("winappcli-POC")]
|
||||
[DynamicData(nameof(NavigationCases), DynamicDataDisplayName = nameof(GetNavCaseDisplayName))]
|
||||
public void NavigationItem_NavigatesWithoutCrashing(string parentGroupSlug, string navItemSlug)
|
||||
{
|
||||
// The Settings window is shared across the class, so a parent group may already be expanded
|
||||
// from a previous case. Only expand it when the child isn't already in the tree — clicking
|
||||
// an already-expanded group would collapse it.
|
||||
if (!string.IsNullOrEmpty(parentGroupSlug) && !Session.Has(By.AccessibilityId(navItemSlug), 500))
|
||||
{
|
||||
Find<NavigationViewItem>(By.AccessibilityId(parentGroupSlug)).Click();
|
||||
}
|
||||
|
||||
// Child item is only in the visual tree once its parent is expanded; Find polls for up to
|
||||
// timeoutMS so the expand animation doesn't race us.
|
||||
Find<NavigationViewItem>(By.AccessibilityId(navItemSlug), timeoutMS: 5_000).Click();
|
||||
|
||||
// Brief settle so any unhandled exception in the page constructor or navigation handler
|
||||
// has time to land in RoFailFast.
|
||||
Thread.Sleep(250);
|
||||
|
||||
// Check by process name, not by launcher PID. Settings is single-instance: the EXE the
|
||||
// framework started often exits cleanly after handing off to an existing instance, so the
|
||||
// actual window may be owned by a different PID than the one we launched.
|
||||
Assert.IsTrue(
|
||||
SessionHelper.IsRunning(Scope),
|
||||
$"No {ScopeProcessName} process remains after invoking '{navItemSlug}'. " +
|
||||
"Likely a navigation FailFast regression \u2014 see ShellViewModel.Frame_NavigationFailed.");
|
||||
}
|
||||
|
||||
private readonly record struct NavigationCase(string? ParentGroupSlug, string NavItemSlug);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user