Compare commits
47 Commits
pt-team/ui
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cccd2b7510 | ||
|
|
e4ef90d168 | ||
|
|
e0fe3c48cf | ||
|
|
029dd04ce4 | ||
|
|
3d3cef73da | ||
|
|
03e5f3e837 | ||
|
|
9039451e2f | ||
|
|
af45c3ec7c | ||
|
|
de4859454c | ||
|
|
93669df118 | ||
|
|
56fabda79c | ||
|
|
70555459ab | ||
|
|
d6319516d0 | ||
|
|
53737cbe31 | ||
|
|
bf6ff579d3 | ||
|
|
0afe525f31 | ||
|
|
a43fb12d6f | ||
|
|
bc56443443 | ||
|
|
3298625b67 | ||
|
|
ae9f241ef1 | ||
|
|
67a9fa2d13 | ||
|
|
1cfc923bdb | ||
|
|
2dd802f367 | ||
|
|
a0d17406ba | ||
|
|
4a27c5d5f9 | ||
|
|
8bd5c1be6f | ||
|
|
7b19b4c219 | ||
|
|
b73fd670be | ||
|
|
a46a4437e5 | ||
|
|
3bf682048e | ||
|
|
28a9bbe8f0 | ||
|
|
536e768cac | ||
|
|
70ff4013b9 | ||
|
|
7a04d4c270 | ||
|
|
8c434cd6f4 | ||
|
|
d983dbc285 | ||
|
|
fb6843b0f1 | ||
|
|
6dd1ce5dd1 | ||
|
|
9ea30ec523 | ||
|
|
c777fcc1e4 | ||
|
|
28e078897a | ||
|
|
64f1243bdf | ||
|
|
e1074bc835 | ||
|
|
2390aacbfc | ||
|
|
a864d421fc | ||
|
|
3331bdf02a | ||
|
|
9ee0c7259b |
12
.github/actions/spell-check/expect.txt
vendored
@@ -135,6 +135,7 @@ BITMAPINFO
|
||||
BITMAPINFOHEADER
|
||||
BITSPERPEL
|
||||
BITSPIXEL
|
||||
Blackmagic
|
||||
bla
|
||||
BLENDFUNCTION
|
||||
blittable
|
||||
@@ -539,6 +540,7 @@ EXTRINSICPROPERTIES
|
||||
eyetracker
|
||||
FANCYZONESDRAWLAYOUTTEST
|
||||
FANCYZONESEDITOR
|
||||
Fairlight
|
||||
FARPROC
|
||||
fdw
|
||||
fdx
|
||||
@@ -621,7 +623,9 @@ GETPROPERTYSTOREFLAGS
|
||||
GETSCREENSAVERRUNNING
|
||||
GETSECKEY
|
||||
GETSTICKYKEYS
|
||||
GETTASKBARPOS
|
||||
GETTEXTLENGTH
|
||||
GETWORKAREA
|
||||
gfx
|
||||
GHND
|
||||
gitmodules
|
||||
@@ -834,6 +838,7 @@ INTRESOURCE
|
||||
INVALIDARG
|
||||
invalidoperatioexception
|
||||
invokecommand
|
||||
iOS
|
||||
ipcmanager
|
||||
ipreviewhandlervisualssetfont
|
||||
IPTC
|
||||
@@ -1230,6 +1235,8 @@ NOTSRCCOPY
|
||||
NOTSRCERASE
|
||||
Notupdated
|
||||
notwindows
|
||||
NOTXORPEN
|
||||
Nouveaut
|
||||
nowarn
|
||||
NOZORDER
|
||||
NPH
|
||||
@@ -1637,6 +1644,7 @@ SETPOWEROFFACTIVE
|
||||
SETRANGE
|
||||
SETREDRAW
|
||||
SETRULES
|
||||
SETAUTOHIDEBAREX
|
||||
SETSCREENSAVEACTIVE
|
||||
SETSTICKYKEYS
|
||||
SETTEXT
|
||||
@@ -1913,6 +1921,7 @@ tracerpt
|
||||
trackbar
|
||||
trafficmanager
|
||||
transicc
|
||||
transitioning
|
||||
TRAYMOUSEMESSAGE
|
||||
triaging
|
||||
trl
|
||||
@@ -2075,8 +2084,6 @@ wifi
|
||||
wikimedia
|
||||
wikipedia
|
||||
winapi
|
||||
winapp
|
||||
winappcli
|
||||
winappsdk
|
||||
windir
|
||||
WINDOWCREATED
|
||||
@@ -2176,6 +2183,7 @@ xclip
|
||||
xcopy
|
||||
xdf
|
||||
xfd
|
||||
xhair
|
||||
xmp
|
||||
Xoshiro
|
||||
xsi
|
||||
|
||||
6
.github/copilot-instructions.md
vendored
@@ -30,6 +30,12 @@ These are auto-applied based on file location:
|
||||
- [Runner & Settings UI](.github/instructions/runner-settings-ui.instructions.md)
|
||||
- [Common Libraries](.github/instructions/common-libraries.instructions.md)
|
||||
|
||||
## Shortcut Guide V2 Manifests
|
||||
|
||||
When creating or editing Shortcut Guide keyboard shortcut manifest files, follow the schema and naming conventions in the spec:
|
||||
|
||||
- [WinGet Manifest Keyboard Shortcuts schema](<../doc/specs/WinGet Manifest Keyboard Shortcuts schema.md>) – manifest file format, field definitions, file naming, and the `+` prefix convention for apps without a WinGet package
|
||||
|
||||
## Detailed Documentation
|
||||
|
||||
- [Architecture](../doc/devdocs/core/architecture.md)
|
||||
|
||||
201
.github/skills/ui-tests-migration/LICENSE.txt
vendored
@@ -1,201 +0,0 @@
|
||||
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
@@ -1,192 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,171 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,167 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,357 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,187 +0,0 @@
|
||||
# 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).
|
||||
@@ -1,173 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,54 +0,0 @@
|
||||
<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>
|
||||
@@ -1,144 +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.
|
||||
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
@@ -1,218 +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.
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
<?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>
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: wpf-to-winui3-migration
|
||||
description: Guide for migrating PowerToys modules from WPF to WinUI 3 (Windows App SDK). Use when asked to migrate WPF code, convert WPF XAML to WinUI, replace System.Windows namespaces with Microsoft.UI.Xaml, update Dispatcher to DispatcherQueue, replace DynamicResource with ThemeResource, migrate imaging APIs from System.Windows.Media.Imaging to Windows.Graphics.Imaging, convert WPF Window to WinUI Window, migrate .resx to .resw resources, migrate custom Observable/RelayCommand to CommunityToolkit.Mvvm source generators, handle WPF-UI (Lepo) to WinUI native control migration, or fix installer/build pipeline issues after migration. Keywords: WPF, WinUI, WinUI3, migration, porting, convert, namespace, XAML, Dispatcher, DispatcherQueue, imaging, BitmapImage, Window, ContentDialog, ThemeResource, DynamicResource, ResourceLoader, resw, resx, CommunityToolkit, ObservableProperty, WPF-UI, SizeToContent, AppWindow, SoftwareBitmap.
|
||||
description: 'Guide for migrating PowerToys modules from WPF to WinUI 3 (Windows App SDK). Use when asked to migrate WPF code, convert WPF XAML to WinUI, replace System.Windows namespaces with Microsoft.UI.Xaml, update Dispatcher to DispatcherQueue, replace DynamicResource with ThemeResource, migrate imaging APIs from System.Windows.Media.Imaging to Windows.Graphics.Imaging, convert WPF Window to WinUI Window, migrate .resx to .resw resources, migrate custom Observable/RelayCommand to CommunityToolkit.Mvvm source generators, handle WPF-UI (Lepo) to WinUI native control migration, or fix installer/build pipeline issues after migration. Keywords: WPF, WinUI, WinUI3, migration, porting, convert, namespace, XAML, Dispatcher, DispatcherQueue, imaging, BitmapImage, Window, ContentDialog, ThemeResource, DynamicResource, ResourceLoader, resw, resx, CommunityToolkit, ObservableProperty, WPF-UI, SizeToContent, AppWindow, SoftwareBitmap.'
|
||||
license: Complete terms in LICENSE.txt
|
||||
---
|
||||
|
||||
|
||||
7
.github/workflows/auto-labeler.yml
vendored
@@ -73,6 +73,13 @@ jobs:
|
||||
|
||||
const itemType = issue.pull_request ? 'Pull request' : 'Issue';
|
||||
|
||||
// Skip pull requests that already have labels applied.
|
||||
if (issue.pull_request && issue.labels && issue.labels.length > 0) {
|
||||
const existingLabels = issue.labels.map(l => l.name).join(', ');
|
||||
console.log(`${itemType} #${issueNumber} already has labels (${existingLabels}); skipping.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const title = issue.title ?? '';
|
||||
const body = issue.body ?? '';
|
||||
|
||||
|
||||
@@ -211,12 +211,12 @@
|
||||
"WinUI3Apps\\NewPlusPackage.msix",
|
||||
"WinUI3Apps\\PowerToys.NewPlus.ShellExtension.win10.dll",
|
||||
|
||||
"PowerAccent.Core.dll",
|
||||
"PowerAccent.Common.dll",
|
||||
"PowerToys.PowerAccent.dll",
|
||||
"PowerToys.PowerAccent.exe",
|
||||
"WinUI3Apps\\PowerAccent.Core.dll",
|
||||
"WinUI3Apps\\PowerAccent.Common.dll",
|
||||
"WinUI3Apps\\PowerToys.PowerAccent.dll",
|
||||
"WinUI3Apps\\PowerToys.PowerAccent.exe",
|
||||
"PowerToys.PowerAccentModuleInterface.dll",
|
||||
"PowerToys.PowerAccentKeyboardService.dll",
|
||||
"WinUI3Apps\\PowerToys.PowerAccentKeyboardService.dll",
|
||||
|
||||
"PowerToys.PowerDisplayModuleInterface.dll",
|
||||
"WinUI3Apps\\PowerToys.PowerDisplay.dll",
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
[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,20 +1,15 @@
|
||||
param(
|
||||
[Parameter()]
|
||||
[ValidateSet("Machine", "PerUser")]
|
||||
[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
|
||||
[string]$InstallMode = "Machine"
|
||||
)
|
||||
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
|
||||
# Get artifact path
|
||||
$ArtifactPath = $ENV:BUILD_ARTIFACTSTAGINGDIRECTORY
|
||||
if (-not $ArtifactPath) {
|
||||
throw "Installer path not provided. Pass -ArtifactPath or set BUILD_ARTIFACTSTAGINGDIRECTORY."
|
||||
throw "BUILD_ARTIFACTSTAGINGDIRECTORY environment variable not set"
|
||||
}
|
||||
|
||||
# Since we only download PowerToysSetup-*.exe files, we can directly find it
|
||||
|
||||
@@ -171,11 +171,6 @@ 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 = ""
|
||||
@@ -469,6 +464,11 @@ 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,28 +90,15 @@ jobs:
|
||||
reg add "HKLM\Software\Policies\Microsoft\Edge\WebView2\ReleaseChannels" /v PowerToys.exe /t REG_SZ /d "3"
|
||||
displayName: "Enable WebView2 Canary Channel"
|
||||
|
||||
- ${{ 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/**
|
||||
- ${{ if ne(parameters.platform, 'arm64') }}:
|
||||
- download: current
|
||||
displayName: Download artifacts
|
||||
artifact: $(TestArtifactsName)
|
||||
patterns: |-
|
||||
**
|
||||
!**\*.pdb
|
||||
!**\*.lib
|
||||
- ${{ 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)
|
||||
@@ -119,20 +106,13 @@ jobs:
|
||||
- template: steps-ensure-dotnet-version.yml
|
||||
parameters:
|
||||
sdk: true
|
||||
version: '10.0'
|
||||
version: '9.0'
|
||||
|
||||
- pwsh: |-
|
||||
& '$(build.sourcesdirectory)\.pipelines\InstallWinAppDriver.ps1'
|
||||
displayName: Download and install WinAppDriver
|
||||
|
||||
# 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')) }}:
|
||||
- ${{ if ne(parameters.buildSource, 'buildNow') }}:
|
||||
- task: DownloadPipelineArtifact@2
|
||||
inputs:
|
||||
buildType: 'specific'
|
||||
@@ -153,7 +133,7 @@ jobs:
|
||||
patterns: |
|
||||
**/PowerToysSetup*.exe
|
||||
|
||||
- ${{ if and(ne(parameters.buildSource, 'buildNow'), ne(parameters.buildSource, 'buildNowSlim')) }}:
|
||||
- ${{ if ne(parameters.buildSource, 'buildNow') }}:
|
||||
- ${{ if eq(parameters.installMode, 'peruser') }}:
|
||||
- pwsh: |-
|
||||
& "$(build.sourcesdirectory)\.pipelines\installPowerToys.ps1" -InstallMode "PerUser"
|
||||
@@ -164,137 +144,12 @@ 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'
|
||||
|
||||
# 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
|
||||
}
|
||||
- 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)
|
||||
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,7 +26,6 @@ parameters:
|
||||
values:
|
||||
- latestMainOfficialBuild
|
||||
- buildNow
|
||||
- buildNowSlim
|
||||
- specificBuildId
|
||||
- name: specificBuildId
|
||||
type: string
|
||||
@@ -38,21 +37,18 @@ parameters:
|
||||
|
||||
stages:
|
||||
- ${{ each platform in parameters.buildPlatforms }}:
|
||||
# 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')) }}:
|
||||
# Full build path: build PowerToys + UI tests + run tests
|
||||
- ${{ if eq(parameters.buildSource, 'buildNow') }}:
|
||||
- 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 and(ne(parameters.buildSource, 'buildNow'), ne(parameters.buildSource, 'buildNowSlim')) }}:
|
||||
# Official build path: build UI tests only + download official build + run tests
|
||||
- ${{ if ne(parameters.buildSource, 'buildNow') }}:
|
||||
- template: pipeline-ui-tests-official-build.yml
|
||||
parameters:
|
||||
platform: ${{ platform }}
|
||||
|
||||
@@ -2,11 +2,6 @@
|
||||
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
|
||||
@@ -58,7 +53,7 @@ stages:
|
||||
platform: x64Win10
|
||||
configuration: Release
|
||||
useLatestWebView2: ${{ parameters.useLatestWebView2 }}
|
||||
buildSource: ${{ parameters.buildSource }}
|
||||
buildSource: 'buildNow'
|
||||
uiTestModules: ${{ parameters.uiTestModules }}
|
||||
|
||||
- stage: Test_x64Win11_FullBuild
|
||||
@@ -70,7 +65,7 @@ stages:
|
||||
platform: x64Win11
|
||||
configuration: Release
|
||||
useLatestWebView2: ${{ parameters.useLatestWebView2 }}
|
||||
buildSource: ${{ parameters.buildSource }}
|
||||
buildSource: 'buildNow'
|
||||
uiTestModules: ${{ parameters.uiTestModules }}
|
||||
|
||||
- ${{ if ne(parameters.platform, 'x64') }}:
|
||||
@@ -83,5 +78,5 @@ stages:
|
||||
platform: ${{ parameters.platform }}
|
||||
configuration: Release
|
||||
useLatestWebView2: ${{ parameters.useLatestWebView2 }}
|
||||
buildSource: ${{ parameters.buildSource }}
|
||||
buildSource: 'buildNow'
|
||||
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 10 | See [Fuzzing Tests](doc/devdocs/tools/fuzzingtesting.md) |
|
||||
| Fuzz Tests | OneFuzz, .NET 8 | See [Fuzzing Tests](doc/devdocs/tools/fuzzingtesting.md) |
|
||||
|
||||
### Test discipline
|
||||
|
||||
|
||||
@@ -66,10 +66,7 @@
|
||||
<LanguageStandard>stdcpplatest</LanguageStandard>
|
||||
<BuildStlModules>false</BuildStlModules>
|
||||
<!-- TODO: _SILENCE_STDEXT_ARR_ITERS_DEPRECATION_WARNING for compatibility with VS 17.8. Check if we can remove. -->
|
||||
<!-- 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>
|
||||
<PreprocessorDefinitions>_SILENCE_STDEXT_ARR_ITERS_DEPRECATION_WARNING;_UNICODE;UNICODE;%(PreprocessorDefinitions)</PreprocessorDefinitions>
|
||||
<!-- CLR + CFG are not compatible >:{ -->
|
||||
<ControlFlowGuard Condition="'$(CLRSupport)' == ''">Guard</ControlFlowGuard>
|
||||
<DebugInformationFormat Condition="'%(ControlFlowGuard)' == 'Guard'">ProgramDatabase</DebugInformationFormat>
|
||||
|
||||
@@ -4,6 +4,29 @@
|
||||
<Import Project="$(MSBuildCachePackageRoot)\build\$(MSBuildCachePackageName).targets" Condition="'$(MSBuildCacheEnabled)' == 'true'" />
|
||||
<Import Project="$(MSBuildCacheSharedCompilationPackageRoot)\build\Microsoft.MSBuildCache.SharedCompilation.targets" Condition="'$(MSBuildCacheEnabled)' == 'true'" />
|
||||
|
||||
<!--
|
||||
Onboarding guard: PowerToys has deeply nested source paths that exceed the legacy
|
||||
260-character MAX_PATH limit. Without Windows long path support enabled, the build
|
||||
fails with cryptic "path too long" / "could not find file" errors that are hard for
|
||||
new contributors to diagnose. Detect the missing registry setting up front and emit a
|
||||
clear, actionable error before the confusing failures occur.
|
||||
|
||||
- Covers both Visual Studio (Ctrl+Shift+B) and the command-line build scripts.
|
||||
- Runs only during real builds (skips design-time/IntelliSense passes).
|
||||
- Bypass with /p:SkipLongPathsCheck=true if you know what you're doing.
|
||||
See tools\build\setup-dev-environment.ps1 to enable everything automatically.
|
||||
-->
|
||||
<Target Name="EnsureLongPathsEnabled"
|
||||
BeforeTargets="PrepareForBuild"
|
||||
Condition="'$(DesignTimeBuild)' != 'true' and '$(SkipLongPathsCheck)' != 'true' and '$(OS)' == 'Windows_NT'">
|
||||
<PropertyGroup>
|
||||
<_LongPathsEnabled>$([MSBuild]::GetRegistryValueFromView('HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\FileSystem', 'LongPathsEnabled', null, RegistryView.Registry64))</_LongPathsEnabled>
|
||||
</PropertyGroup>
|
||||
<Error Condition="'$(_LongPathsEnabled)' != '1'"
|
||||
Code="PTLONGPATH"
|
||||
Text="Windows long path support is not enabled. PowerToys source paths exceed the 260-character MAX_PATH limit, so the build will fail with cryptic 'path too long' errors. Fix it by running (from an elevated PowerShell): .\tools\build\setup-dev-environment.ps1 -- or set HKLM\SYSTEM\CurrentControlSet\Control\FileSystem\LongPathsEnabled = 1 (DWORD) and restart Windows. To bypass this check, build with /p:SkipLongPathsCheck=true." />
|
||||
</Target>
|
||||
|
||||
<!-- Override ManifestTool to the x64 host tool under WindowsSdkDir for all projects once the SDK path is known. -->
|
||||
<PropertyGroup Label="ManifestToolOverride">
|
||||
<ManifestTool Condition="Exists('$(WindowsSdkDir)bin\x64\mt.exe')">$(WindowsSdkDir)bin\x64\mt.exe</ManifestTool>
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
<PackageVersion Include="CommunityToolkit.WinUI.Controls.Sizers" Version="8.2.251219" />
|
||||
<PackageVersion Include="CommunityToolkit.WinUI.Converters" Version="8.2.251219" />
|
||||
<PackageVersion Include="CommunityToolkit.WinUI.Extensions" Version="8.2.251219" />
|
||||
<PackageVersion Include="CommunityToolkit.WinUI.UI.Controls.DataGrid" Version="7.1.2" />
|
||||
<PackageVersion Include="CommunityToolkit.WinUI.UI.Controls.DataGrid" Version="7.1.2" />
|
||||
<PackageVersion Include="CommunityToolkit.Labs.WinUI.Controls.MarkdownTextBlock" Version="0.1.260116-build.2514" />
|
||||
<PackageVersion Include="ControlzEx" Version="6.0.0" />
|
||||
<PackageVersion Include="HelixToolkit" Version="2.24.0" />
|
||||
@@ -38,7 +38,7 @@
|
||||
<PackageVersion Include="Mages" Version="3.0.0" />
|
||||
<PackageVersion Include="Markdig.Signed" Version="0.34.0" />
|
||||
<!-- Including MessagePack to force version, since it's used by StreamJsonRpc but contains vulnerabilities. After StreamJsonRpc updates the version of MessagePack, we can upgrade StreamJsonRpc instead. -->
|
||||
<PackageVersion Include="MessagePack" Version="3.1.3" />
|
||||
<PackageVersion Include="MessagePack" Version="3.1.7" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="10.0.102" />
|
||||
<PackageVersion Include="Microsoft.CommandPalette.Extensions" Version="0.9.260303001" />
|
||||
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.8" />
|
||||
@@ -64,7 +64,7 @@
|
||||
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.MistralAI" Version="1.71.0-alpha" />
|
||||
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.Ollama" Version="1.71.0-alpha" />
|
||||
<PackageVersion Include="Microsoft.Toolkit.Uwp.Notifications" Version="7.1.2" />
|
||||
<PackageVersion Include="Microsoft.Web.WebView2" Version="1.0.3719.77" />
|
||||
<PackageVersion Include="Microsoft.Web.WebView2" Version="1.0.4022.49" />
|
||||
<!-- Package Microsoft.Win32.SystemEvents added as a hack for being able to exclude the runtime assets so they don't conflict with 8.0.1. This is a dependency of System.Drawing.Common but the 8.0.1 version wasn't published to nuget. -->
|
||||
<PackageVersion Include="Microsoft.Win32.SystemEvents" Version="10.0.8" />
|
||||
<PackageVersion Include="Microsoft.WindowsPackageManager.ComInterop" Version="1.10.340" />
|
||||
@@ -76,7 +76,7 @@
|
||||
This is present due to a bug in CsWinRT where WPF projects cause the analyzer to fail.
|
||||
-->
|
||||
<PackageVersion Include="Microsoft.Windows.CsWinRT" Version="2.2.0" />
|
||||
<PackageVersion Include="Microsoft.Windows.ImplementationLibrary" Version="1.0.250325.1"/>
|
||||
<PackageVersion Include="Microsoft.Windows.ImplementationLibrary" Version="1.0.250325.1" />
|
||||
<PackageVersion Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.26100.6901" />
|
||||
<PackageVersion Include="Microsoft.WindowsAppSDK" Version="2.2.0" />
|
||||
<PackageVersion Include="Microsoft.WindowsAppSDK.Foundation" Version="2.1.0" />
|
||||
@@ -99,7 +99,6 @@
|
||||
<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. -->
|
||||
@@ -152,4 +151,4 @@
|
||||
<PackageVersion Include="Microsoft.VariantAssignment.Client" Version="2.4.17140001" />
|
||||
<PackageVersion Include="Microsoft.VariantAssignment.Contract" Version="3.0.16990001" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
65
NOTICE.md
@@ -12,6 +12,7 @@ This software incorporates material from third parties.
|
||||
- Peek
|
||||
- PowerDisplay
|
||||
- Registry Preview
|
||||
- ZoomIt
|
||||
|
||||
## Utility: Color Picker
|
||||
|
||||
@@ -1549,6 +1550,69 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
## Utility: ZoomIt
|
||||
|
||||
### libwebp
|
||||
|
||||
ZoomIt uses libwebp to encode screenshots in the WebP image format.
|
||||
|
||||
**Source**: <https://github.com/webmproject/libwebp>
|
||||
|
||||
BSD-3-Clause License
|
||||
|
||||
Copyright (c) 2010, Google Inc. All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are
|
||||
met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
|
||||
* Redistributions in binary form must reproduce the above copyright
|
||||
notice, this list of conditions and the following disclaimer in
|
||||
the documentation and/or other materials provided with the
|
||||
distribution.
|
||||
|
||||
* Neither the name of Google nor the names of its contributors may
|
||||
be used to endorse or promote products derived from this software
|
||||
without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
||||
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
||||
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
Additional IP Rights Grant (Patents)
|
||||
|
||||
"These implementations" means the copyrightable works that implement the WebM
|
||||
codecs distributed by Google as part of the WebM Project.
|
||||
|
||||
Google 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, transfer, and otherwise
|
||||
run, modify and propagate the contents of these implementations of WebM, where
|
||||
such license applies only to those patent claims, both currently owned by
|
||||
Google and acquired in the future, licensable by Google that are necessarily
|
||||
infringed by these implementations of WebM. This grant does not include claims
|
||||
that would be infringed only as a consequence of further modification of these
|
||||
implementations. If you or your agent or exclusive licensee institute or order
|
||||
or agree to the institution of patent litigation or any other patent
|
||||
enforcement activity against any entity (including a cross-claim or
|
||||
counterclaim in a lawsuit) alleging that any of these implementations of WebM
|
||||
or any code incorporated within any of these implementations of WebM
|
||||
constitute direct or contributory patent infringement, or inducement of
|
||||
patent infringement, then any patent rights granted to you under this License
|
||||
for these implementations of WebM shall terminate as of the date such
|
||||
litigation is filed.
|
||||
|
||||
## NuGet Packages used by PowerToys
|
||||
|
||||
- AdaptiveCards.ObjectModel.WinUI3
|
||||
@@ -1589,7 +1653,6 @@ 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.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>
|
||||
<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>
|
||||
@@ -54,14 +54,10 @@
|
||||
<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/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/updating/UnitTests/UpdatingUnitTests.vcxproj" Id="a1b2c3d4-e5f6-7890-abcd-ef1234567890" />
|
||||
<Project Path="src/common/version/version.vcxproj" Id="cc6e41ac-8174-4e8a-8d22-85dd7f4851df" />
|
||||
</Folder>
|
||||
<Folder Name="/common/interop/">
|
||||
@@ -194,10 +190,6 @@
|
||||
<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" />
|
||||
@@ -208,11 +200,11 @@
|
||||
</Project>
|
||||
</Folder>
|
||||
<Folder Name="/modules/CommandPalette/Built-in Extensions/">
|
||||
<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>
|
||||
<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>
|
||||
@@ -726,11 +718,11 @@
|
||||
</Project>
|
||||
</Folder>
|
||||
<Folder Name="/modules/PowerDisplay/">
|
||||
<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>
|
||||
<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>
|
||||
@@ -763,10 +755,6 @@
|
||||
<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">
|
||||
@@ -818,6 +806,10 @@
|
||||
<Platform Solution="*|ARM64" Project="ARM64" />
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
</Project>
|
||||
<Project Path="src/modules/poweraccent/PowerAccent.Core.UnitTests/PowerAccent.Core.UnitTests.csproj">
|
||||
<Platform Solution="*|ARM64" Project="ARM64" />
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
</Project>
|
||||
<Project Path="src/modules/poweraccent/PowerAccent.Common/PowerAccent.Common.csproj">
|
||||
<Platform Solution="*|ARM64" Project="ARM64" />
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
@@ -1107,10 +1099,6 @@
|
||||
<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" />
|
||||
@@ -1142,14 +1130,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" />
|
||||
|
||||
@@ -15,14 +15,15 @@ Quick Accent (formerly known as Power Accent) is a PowerToys module that allows
|
||||
|
||||
## Architecture
|
||||
|
||||
The Quick Accent module consists of four main components:
|
||||
The Quick Accent module consists of five projects:
|
||||
|
||||
```
|
||||
poweraccent/
|
||||
├── PowerAccent.Core/ # Core component containing Language Sets
|
||||
├── PowerAccent.UI/ # The character selector UI
|
||||
├── PowerAccentKeyboardService/ # Keyboard Hook
|
||||
└── PowerAccentModuleInterface/ # DLL interface
|
||||
├── PowerAccent.Common/ # Language data, character mappings, LetterKey enum
|
||||
├── PowerAccent.Core/ # Accent logic, settings, positioning, usage statistics
|
||||
├── PowerAccent.UI/ # WinUI 3 character selector app (PowerToys.PowerAccent.exe)
|
||||
├── PowerAccentKeyboardService/ # WinRT keyboard-hook component
|
||||
└── PowerAccentModuleInterface/ # Native runner module DLL
|
||||
```
|
||||
|
||||
### Module Interface (PowerAccentModuleInterface)
|
||||
@@ -32,21 +33,32 @@ The Module Interface, implemented in `PowerAccentModuleInterface/dllmain.cpp`, i
|
||||
- Managing module lifecycle (enable/disable/settings)
|
||||
- Launching and terminating the PowerToys.PowerAccent.exe process
|
||||
|
||||
### Shared Data (PowerAccent.Common)
|
||||
|
||||
`PowerAccent.Common` holds the UI- and runtime-agnostic data the other projects share:
|
||||
- The language / character-set definitions and per-letter accent mappings
|
||||
- The managed `LetterKey` enum (kept in sync with the WinRT `LetterKey` in `PowerAccentKeyboardService/KeyboardListener.idl`)
|
||||
|
||||
It has no UI or WinRT dependencies and is unit-tested in isolation (`PowerAccent.Common.UnitTests`).
|
||||
|
||||
### Core Logic (PowerAccent.Core)
|
||||
|
||||
The Core component contains:
|
||||
- Main accent character logic
|
||||
- Keyboard input detection
|
||||
- Character mappings for different languages
|
||||
- Management of language sets and special characters (currency, math symbols, etc.)
|
||||
- Usage statistics for frequently used characters
|
||||
- Main accent character logic, consuming the language data from `PowerAccent.Common`
|
||||
- Toolbar positioning math (9 anchor points with per-monitor DPI) and settings handling
|
||||
- Management of special characters (currency, math symbols, etc.) and usage statistics
|
||||
|
||||
Core carries no UI-framework dependency: it raises events and accepts a UI-thread marshaller delegate instead of touching WPF/WinUI directly, and its positioning math is covered by `PowerAccent.Core.UnitTests`.
|
||||
|
||||
### UI Layer (PowerAccent.UI)
|
||||
|
||||
The UI component is responsible for:
|
||||
- Displaying the toolbar with accent options
|
||||
- Handling user selection of accented characters
|
||||
- Managing the visual positioning of the toolbar
|
||||
The UI component is a self-contained **WinUI 3 (Windows App SDK)** app, migrated from WPF.
|
||||
It is responsible for:
|
||||
- Displaying the accent toolbar — a non-activating, always-on-top `TransparentWindow` overlay shown with `SW_SHOWNA` so it never steals focus from the app being typed into
|
||||
- Handling selection and the toolbar's sizing / positioning
|
||||
- Following the system theme while the long-lived process runs
|
||||
|
||||
It builds to `PowerToys.PowerAccent.exe` together with its `.pri` and the bundled Windows App SDK runtime, all under the `WinUI3Apps` output folder.
|
||||
|
||||
### Keyboard Service (PowerAccentKeyboardService)
|
||||
|
||||
@@ -59,13 +71,26 @@ This component:
|
||||
|
||||
### Activation Mechanism
|
||||
|
||||
The Quick Accent is activated when:
|
||||
Quick Accent supports two activation styles, selected by the **Activation key** setting.
|
||||
|
||||
**Trigger-key modes** (`Left/Right arrow`, `Space`, or `Both` — the default):
|
||||
1. A user presses and holds a character key (e.g., 'a')
|
||||
2. User presses the trigger key
|
||||
3. After a brief delay (around 300ms per setting), the accent toolbar appears
|
||||
4. The user can select an accented variant using the trigger key
|
||||
5. Upon releasing the keys, the selected accented character is inserted
|
||||
|
||||
**Press-and-hold mode** (`Press and hold the letter`, iOS/macOS style, opt-in):
|
||||
1. A user presses and holds an accent-capable character key (e.g., 'a'); the base
|
||||
letter is typed immediately
|
||||
2. After the configured **Hold duration** (around 500ms per setting), the accent
|
||||
toolbar appears automatically — no separate trigger key is required
|
||||
3. The user navigates the options with the arrow keys or Space
|
||||
4. Upon releasing the letter, the selected accent replaces the base letter; if no
|
||||
option was selected, the base letter that was already typed simply remains
|
||||
5. A quick tap (shorter than the Hold duration) types the base letter only, and
|
||||
modifier combinations (Ctrl/Alt/AltGr/Win + letter) are left untouched
|
||||
|
||||
### Character Sets
|
||||
|
||||
The module includes multiple language-specific character sets and special character sets:
|
||||
@@ -115,5 +140,5 @@ To directly debug the Quick Accent UI component:
|
||||
5. Start debugging by pressing `F5` or clicking the "*Start*" button
|
||||
6. Verify that the debugger breaks at your breakpoint and you can inspect variables and step through code
|
||||
|
||||
**Known issue**: You may encounter approximately 78 errors during the start of debugging.<br>
|
||||
**Solution**: If you encounter errors, right-click on the **PowerAccent** folder in Solution Explorer and select "*Rebuild*". After rebuilding, start debugging again.
|
||||
**Known issue**: A first incremental build can surface transient errors (for example from CsWinRT projection / WinUI XAML codegen ordering).<br>
|
||||
**Solution**: Right-click the **PowerAccent** folder in Solution Explorer and select "*Rebuild*", then start debugging again.
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
# [CleanUp_tool](/tools/CleanUp_tool/) and [CleanUp_tool_powershell_script](/tools/CleanUp_tool_powershell_script/CleanUp_tool.ps1)
|
||||
|
||||
This tool, respective this powershell script, is used to clean up the PowerToys installation. It cleans the `AppData` folder and the registry.
|
||||
|
||||
This tool is currently very outdated and just cleans up the registry keys of some few modules.
|
||||
@@ -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 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.
|
||||
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.
|
||||
|
||||
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\\net10.0-windows10.0.26100.0\\**"
|
||||
"PowerToys\\x64\\Debug\\tests\\YourModule.FuzzTests\\net8.0-windows10.0.19041.0\\**"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ Following tools are currently available:
|
||||
|
||||
* [BugReportTool](bug-report-tool.md) - A tool to collect logs and system information for bug reports.
|
||||
* [Build tools](build-tools.md) - A set of scripts that help building PowerToys.
|
||||
* [Clean up tool](clean-up-tool.md) - A tool to clean up the PowerToys installation.
|
||||
* [Monitor info report](monitor-info-report.md) - A small diagnostic tool which helps identifying WinAPI bugs related to the physical monitor detection.
|
||||
* [project template](/tools/project_template/README.md) - A Visual Studio project template for a new PowerToys project.
|
||||
* [StylesReportTool](styles-report-tool.md) - A tool to collect information about an open window.
|
||||
|
||||
@@ -109,7 +109,7 @@ Per Application/Package one or more Keyboard manifests can be declared. Every ma
|
||||
<details>
|
||||
<summary><b>SectionName</b> - Name of the category of shortcuts</summary>
|
||||
|
||||
Name of the section of shortcuts.
|
||||
Name of the section of shortcuts. Use sentence case, the same convention described under `Name` below.
|
||||
|
||||
**Special sections**:
|
||||
|
||||
@@ -126,6 +126,10 @@ Special sections start with an identifier enclosed between `<` and `>`. This dec
|
||||
|
||||
Name of the shortcut. This is the name that will be displayed in the interpreter.
|
||||
|
||||
**Casing**:
|
||||
|
||||
By convention, shortcut names (and `SectionName` values) use **sentence case**: capitalize only the first word plus any proper nouns or product/feature names. For example, prefer `Reopen last closed tab` over `Reopen Last Closed Tab`, but keep `Open History`, `Quit Slack`, and `Show Quick Access` capitalized because those are application feature names. Match the casing the application uses for its own features rather than copying the title-case styling some apps apply to their entire shortcut list.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
<?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">
|
||||
<!-- 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. -->
|
||||
<!-- 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. -->
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0-windows10.0.26100.0</TargetFramework>
|
||||
<TargetFramework>net8.0-windows10.0.26100.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<!--
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<packages>
|
||||
<package id="Microsoft.Windows.ImplementationLibrary" version="1.0.250325.1" targetFramework="native" />
|
||||
</packages>
|
||||
<package id="Microsoft.Windows.ImplementationLibrary" version="1.0.260126.7" targetFramework="native" />
|
||||
</packages>
|
||||
@@ -29,8 +29,30 @@ namespace Microsoft.PowerToys.Common.UI.Controls.Backdrops;
|
||||
/// </remarks>
|
||||
public sealed partial class AlwaysActiveDesktopAcrylicBackdrop : SystemBackdrop
|
||||
{
|
||||
/// <summary>
|
||||
/// Identifies the <see cref="Kind"/> dependency property.
|
||||
/// </summary>
|
||||
public static readonly DependencyProperty KindProperty = DependencyProperty.Register(
|
||||
nameof(Kind),
|
||||
typeof(DesktopAcrylicKind),
|
||||
typeof(AlwaysActiveDesktopAcrylicBackdrop),
|
||||
new PropertyMetadata(DesktopAcrylicKind.Default, OnKindChanged));
|
||||
|
||||
private readonly Dictionary<ICompositionSupportsSystemBackdrop, BackdropTarget> _targets = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the desktop acrylic material variant to render. Defaults to
|
||||
/// <see cref="DesktopAcrylicKind.Default"/> (the standard, more opaque
|
||||
/// acrylic); <see cref="DesktopAcrylicKind.Thin"/> renders a lighter, more
|
||||
/// translucent material and <see cref="DesktopAcrylicKind.Base"/> the base
|
||||
/// material. Changing this updates any live backdrop targets immediately.
|
||||
/// </summary>
|
||||
public DesktopAcrylicKind Kind
|
||||
{
|
||||
get => (DesktopAcrylicKind)GetValue(KindProperty);
|
||||
set => SetValue(KindProperty, value);
|
||||
}
|
||||
|
||||
protected override void OnTargetConnected(ICompositionSupportsSystemBackdrop connectedTarget, XamlRoot xamlRoot)
|
||||
{
|
||||
base.OnTargetConnected(connectedTarget, xamlRoot);
|
||||
@@ -41,7 +63,10 @@ public sealed partial class AlwaysActiveDesktopAcrylicBackdrop : SystemBackdrop
|
||||
Theme = ResolveTheme(xamlRoot),
|
||||
};
|
||||
|
||||
var controller = new DesktopAcrylicController();
|
||||
var controller = new DesktopAcrylicController
|
||||
{
|
||||
Kind = Kind,
|
||||
};
|
||||
controller.SetSystemBackdropConfiguration(configuration);
|
||||
controller.AddSystemBackdropTarget(connectedTarget);
|
||||
|
||||
@@ -70,6 +95,17 @@ public sealed partial class AlwaysActiveDesktopAcrylicBackdrop : SystemBackdrop
|
||||
}
|
||||
}
|
||||
|
||||
private static void OnKindChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
|
||||
{
|
||||
var self = (AlwaysActiveDesktopAcrylicBackdrop)d;
|
||||
var kind = (DesktopAcrylicKind)e.NewValue;
|
||||
|
||||
foreach (var target in self._targets.Values)
|
||||
{
|
||||
target.Controller.Kind = kind;
|
||||
}
|
||||
}
|
||||
|
||||
private static SystemBackdropTheme ResolveTheme(XamlRoot xamlRoot) =>
|
||||
xamlRoot.Content is FrameworkElement rootElement
|
||||
? rootElement.ActualTheme switch
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
xmlns:backdrops="using:Microsoft.PowerToys.Common.UI.Controls.Backdrops"
|
||||
xmlns:local="using:Microsoft.PowerToys.Common.UI.Controls">
|
||||
|
||||
<Style BasedOn="{StaticResource DefaultTransparentCardStyle}" TargetType="local:TransparentCard" />
|
||||
<Style BasedOn="{StaticResource DefaultTransientSurfaceStyle}" TargetType="local:TransientSurface" />
|
||||
|
||||
<Style x:Key="DefaultTransparentCardStyle" TargetType="local:TransparentCard">
|
||||
<Style x:Key="DefaultTransientSurfaceStyle" TargetType="local:TransientSurface">
|
||||
<Setter Property="BorderBrush" Value="{ThemeResource SurfaceStrokeColorDefaultBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="8" />
|
||||
@@ -16,7 +16,7 @@
|
||||
<Setter Property="VerticalContentAlignment" Value="Stretch" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="local:TransparentCard">
|
||||
<ControlTemplate TargetType="local:TransientSurface">
|
||||
<Grid
|
||||
BorderBrush="{TemplateBinding BorderBrush}"
|
||||
BorderThickness="{TemplateBinding BorderThickness}"
|
||||
@@ -27,7 +27,7 @@
|
||||
</Grid.Shadow>
|
||||
<SystemBackdropElement CornerRadius="{TemplateBinding CornerRadius}">
|
||||
<SystemBackdropElement.SystemBackdrop>
|
||||
<backdrops:AlwaysActiveDesktopAcrylicBackdrop />
|
||||
<backdrops:AlwaysActiveDesktopAcrylicBackdrop Kind="{TemplateBinding AcrylicKind}" />
|
||||
</SystemBackdropElement.SystemBackdrop>
|
||||
</SystemBackdropElement>
|
||||
<ContentPresenter
|
||||
@@ -41,5 +41,4 @@
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
</ResourceDictionary>
|
||||
@@ -0,0 +1,467 @@
|
||||
// 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;
|
||||
using CommunityToolkit.WinUI;
|
||||
using CommunityToolkit.WinUI.Animations;
|
||||
using Microsoft.PowerToys.Common.UI.Controls.Window;
|
||||
using Microsoft.UI.Composition.SystemBackdrops;
|
||||
using Microsoft.UI.Dispatching;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
using Microsoft.UI.Xaml.Hosting;
|
||||
|
||||
namespace Microsoft.PowerToys.Common.UI.Controls;
|
||||
|
||||
/// <summary>
|
||||
/// A floating, self-animating "pseudo window" surface for transient PowerToys
|
||||
/// overlays (toasts, banners, indicators). It looks like a control but behaves
|
||||
/// like a lightweight window: it provides the PowerToys-standard chrome — 1 px
|
||||
/// border in <c>SurfaceStrokeColorDefaultBrush</c>, 8 px corner radius, a
|
||||
/// <c>ThemeShadow</c>, and an always-active desktop acrylic backdrop — and owns
|
||||
/// its own show/hide animations.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Designed to be declared as the root content of a
|
||||
/// <see cref="TransparentWindow"/>, which stays animation-agnostic. Call
|
||||
/// <see cref="SubscribeTo"/> once (e.g. from the hosting window's constructor)
|
||||
/// to wire this surface to the window's <see cref="TransparentWindow.Showing"/> /
|
||||
/// <see cref="TransparentWindow.Hiding"/> events. From then on the surface
|
||||
/// animates itself in/out whenever the window is shown or hidden, and uses the
|
||||
/// <see cref="TransparentWindow.Hiding"/> deferral to keep the window visible
|
||||
/// until its out-animation finishes.</para>
|
||||
/// <para>The show transition comes from the window's
|
||||
/// <see cref="TransparentWindow.Show(Transition)"/> call (or from
|
||||
/// <see cref="ShowTransition"/> when shown without one); the hide transition
|
||||
/// always comes from <see cref="HideTransition"/>. Animations target the
|
||||
/// surface itself, so the entire surface (border, acrylic, shadow, inner
|
||||
/// content) animates as one. Apps that want a different look supply their own
|
||||
/// <c>Style TargetType="TransientSurface"</c> in resources — the standard WinUI
|
||||
/// restyle path.</para>
|
||||
/// </remarks>
|
||||
public sealed partial class TransientSurface : ContentControl
|
||||
{
|
||||
private const float ShadowDepth = 32f;
|
||||
private const double SlideInOffset = 24;
|
||||
private const double SlideOutOffset = 12;
|
||||
|
||||
// "Pop" transition: scale between 96% and 100% (a subtle 4% grow). Following
|
||||
// Fluent motion guidance the scale uses a decelerate (EaseOut) curve; the
|
||||
// fade is kept fast so the surface reads as an instant, light pop.
|
||||
//
|
||||
// The fade must run at least as long as the scale: if the scale outlasted the
|
||||
// fade, the surface would reach full opacity while still visibly growing,
|
||||
// which reads as a "resize" rather than a pop. Keeping the fade >= the scale
|
||||
// hides the growth under the opacity ramp, so by the time it is fully opaque
|
||||
// it is already at 100% size.
|
||||
private const float PopScaleFrom = 0.96f;
|
||||
private const double PopFadeShowMs = 180;
|
||||
private const double PopScaleShowMs = 150;
|
||||
private const double PopFadeHideMs = 120;
|
||||
private const double PopScaleHideMs = 120;
|
||||
|
||||
public static readonly DependencyProperty ShowTransitionProperty = DependencyProperty.Register(
|
||||
nameof(ShowTransition),
|
||||
typeof(Transition),
|
||||
typeof(TransientSurface),
|
||||
new PropertyMetadata(Transition.None, OnTransitionChanged));
|
||||
|
||||
public static readonly DependencyProperty HideTransitionProperty = DependencyProperty.Register(
|
||||
nameof(HideTransition),
|
||||
typeof(Transition),
|
||||
typeof(TransientSurface),
|
||||
new PropertyMetadata(Transition.None, OnTransitionChanged));
|
||||
|
||||
public static readonly DependencyProperty AcrylicKindProperty = DependencyProperty.Register(
|
||||
nameof(AcrylicKind),
|
||||
typeof(DesktopAcrylicKind),
|
||||
typeof(TransientSurface),
|
||||
new PropertyMetadata(DesktopAcrylicKind.Thin));
|
||||
|
||||
private readonly DispatcherQueueTimer _hideCompletedTimer = DispatcherQueue.GetForCurrentThread().CreateTimer();
|
||||
|
||||
private readonly ImplicitAnimationSet _noAnimations = new();
|
||||
|
||||
private ImplicitAnimationSet _showAnimations = new();
|
||||
private ImplicitAnimationSet _hideAnimations = new();
|
||||
private bool _hasCustomShowAnimations;
|
||||
private bool _hasCustomHideAnimations;
|
||||
private Action? _abandonPendingHide;
|
||||
|
||||
public TransientSurface()
|
||||
{
|
||||
DefaultStyleKey = typeof(TransientSurface);
|
||||
|
||||
RebuildDefaultAnimations();
|
||||
|
||||
// Pin the scale center to the surface's center so the "Pop" transition
|
||||
// grows/shrinks from the middle, not the top-left corner. An expression
|
||||
// animation bound to the visual's own size keeps the center correct from
|
||||
// the very first frame (a SizeChanged handler would race the show
|
||||
// animation and let the first pop scale from 0,0).
|
||||
PinScaleCenter();
|
||||
|
||||
// Start hidden so the first Show() animates in from the configured pose.
|
||||
Visibility = Visibility.Collapsed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raised after <see cref="Hide"/> once the longest animation in
|
||||
/// <see cref="HideAnimations"/> (delay + duration) has completed.
|
||||
/// </summary>
|
||||
public event EventHandler? HideCompleted;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the transition played when the surface is shown without an
|
||||
/// explicit one (see <see cref="Show()"/>). Defaults to
|
||||
/// <see cref="Transition.None"/>, which plays no animation at all (the
|
||||
/// surface appears instantly); a directional value adds a fade plus a slide
|
||||
/// in from that edge, and <see cref="Transition.Pop"/> a fade plus a subtle
|
||||
/// scale-up. Changing this regenerates the default <see cref="ShowAnimations"/>
|
||||
/// unless it has been set explicitly.
|
||||
/// </summary>
|
||||
public Transition ShowTransition
|
||||
{
|
||||
get => (Transition)GetValue(ShowTransitionProperty);
|
||||
set => SetValue(ShowTransitionProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the transition played when the surface is hidden (see
|
||||
/// <see cref="Hide"/>). Defaults to <see cref="Transition.None"/>, which
|
||||
/// plays no animation at all (the surface disappears instantly); a
|
||||
/// directional value adds a fade plus a slide out toward that edge, and
|
||||
/// <see cref="Transition.Pop"/> a fade plus a subtle scale-down. Changing
|
||||
/// this regenerates the default <see cref="HideAnimations"/> unless it has
|
||||
/// been set explicitly.
|
||||
/// </summary>
|
||||
public Transition HideTransition
|
||||
{
|
||||
get => (Transition)GetValue(HideTransitionProperty);
|
||||
set => SetValue(HideTransitionProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the desktop acrylic material variant painted behind the
|
||||
/// surface. Defaults to <see cref="DesktopAcrylicKind.Thin"/> (a lighter,
|
||||
/// more translucent material); set <see cref="DesktopAcrylicKind.Default"/>
|
||||
/// for the standard, more opaque acrylic or <see cref="DesktopAcrylicKind.Base"/>
|
||||
/// for the base material. Has no effect when a custom template without the
|
||||
/// default acrylic backdrop is applied.
|
||||
/// </summary>
|
||||
public DesktopAcrylicKind AcrylicKind
|
||||
{
|
||||
get => (DesktopAcrylicKind)GetValue(AcrylicKindProperty);
|
||||
set => SetValue(AcrylicKindProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the animations played when <see cref="Show()"/> flips the
|
||||
/// surface to <see cref="Visibility.Visible"/>. Defaults to the animation
|
||||
/// derived from <see cref="ShowTransition"/>. Assigning a value marks the set
|
||||
/// as custom so <see cref="ShowTransition"/> no longer overwrites it.
|
||||
/// </summary>
|
||||
public ImplicitAnimationSet ShowAnimations
|
||||
{
|
||||
get => _showAnimations;
|
||||
set
|
||||
{
|
||||
_showAnimations = value ?? new ImplicitAnimationSet();
|
||||
_hasCustomShowAnimations = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the animations played when <see cref="Hide"/> flips the
|
||||
/// surface to <see cref="Visibility.Collapsed"/>. Defaults to the animation
|
||||
/// derived from <see cref="HideTransition"/>. Assigning a value marks the set
|
||||
/// as custom so <see cref="HideTransition"/> no longer overwrites it.
|
||||
/// </summary>
|
||||
public ImplicitAnimationSet HideAnimations
|
||||
{
|
||||
get => _hideAnimations;
|
||||
set
|
||||
{
|
||||
_hideAnimations = value ?? new ImplicitAnimationSet();
|
||||
_hasCustomHideAnimations = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wires this surface to a hosting <see cref="TransparentWindow"/> so it
|
||||
/// animates itself in and out in response to the window's
|
||||
/// <see cref="TransparentWindow.Showing"/> / <see cref="TransparentWindow.Hiding"/>
|
||||
/// events. Call this once after the surface has been set as (or placed within)
|
||||
/// the window's content.
|
||||
/// </summary>
|
||||
/// <param name="host">The window whose show/hide transitions drive this surface.</param>
|
||||
public void SubscribeTo(TransparentWindow host)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(host);
|
||||
|
||||
host.Showing += OnHostShowing;
|
||||
host.Hiding += OnHostHiding;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets the surface to its hidden pose and flips it to
|
||||
/// <see cref="Visibility.Visible"/> so <see cref="ShowAnimations"/> plays,
|
||||
/// using <paramref name="transition"/> as the show transition.
|
||||
/// </summary>
|
||||
/// <param name="transition">The transition to play when showing.</param>
|
||||
public void Show(Transition transition)
|
||||
{
|
||||
ShowTransition = transition;
|
||||
Show();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets the surface to its hidden pose and flips it to
|
||||
/// <see cref="Visibility.Visible"/> so <see cref="ShowAnimations"/> plays.
|
||||
/// Repeated calls re-trigger the show animation cleanly and cancel any
|
||||
/// pending <see cref="HideCompleted"/> notification.
|
||||
/// </summary>
|
||||
public void Show()
|
||||
{
|
||||
_hideCompletedTimer.Stop();
|
||||
|
||||
// If a hide from a previous cycle is still in flight, abandon it: drop its
|
||||
// pending HideCompleted handler so the outstanding deferral is never
|
||||
// completed. We are showing again, so the host must keep the window
|
||||
// visible instead of later hiding it for this interrupted cycle.
|
||||
_abandonPendingHide?.Invoke();
|
||||
_abandonPendingHide = null;
|
||||
|
||||
// Attach the show animation and detach any hide animation: when Show() is
|
||||
// called while the surface is still visible, the Collapsed -> Visible
|
||||
// restart below would otherwise play the hide animation (a fade/scale out)
|
||||
// immediately before the show, producing a visible flash. The real hide
|
||||
// animation is re-attached just-in-time in Hide().
|
||||
Implicit.SetShowAnimations(this, _showAnimations);
|
||||
Implicit.SetHideAnimations(this, _noAnimations);
|
||||
|
||||
// Reset to the hidden pose so the show animation always animates from the
|
||||
// configured starting frame.
|
||||
Visibility = Visibility.Collapsed;
|
||||
Visibility = Visibility.Visible;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Flips the surface to <see cref="Visibility.Collapsed"/> so
|
||||
/// <see cref="HideAnimations"/> plays, then raises <see cref="HideCompleted"/>
|
||||
/// once the longest animation in <see cref="HideAnimations"/> (delay +
|
||||
/// duration) has completed.
|
||||
/// </summary>
|
||||
public void Hide()
|
||||
{
|
||||
// Attach the hide animation just before collapsing (Show() detaches it to
|
||||
// avoid a flash when re-showing an already-visible surface).
|
||||
Implicit.SetHideAnimations(this, _hideAnimations);
|
||||
|
||||
Visibility = Visibility.Collapsed;
|
||||
|
||||
_hideCompletedTimer.Debounce(
|
||||
() => HideCompleted?.Invoke(this, EventArgs.Empty),
|
||||
interval: GetAnimationSetTotalDuration(_hideAnimations),
|
||||
immediate: false);
|
||||
}
|
||||
|
||||
private static void OnTransitionChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
|
||||
{
|
||||
((TransientSurface)d).RebuildDefaultAnimations();
|
||||
}
|
||||
|
||||
private static TimeSpan GetAnimationSetTotalDuration(ImplicitAnimationSet set)
|
||||
{
|
||||
TimeSpan longest = TimeSpan.Zero;
|
||||
foreach (var animation in set)
|
||||
{
|
||||
if (animation is Animation anim)
|
||||
{
|
||||
var total = (anim.Delay ?? TimeSpan.Zero) + (anim.Duration ?? TimeSpan.Zero);
|
||||
if (total > longest)
|
||||
{
|
||||
longest = total;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return longest;
|
||||
}
|
||||
|
||||
private static (string? ShowFrom, string? HideTo) GetSlideOffsets(Transition transition) => transition switch
|
||||
{
|
||||
Transition.Bottom => ($"0,{SlideInOffset},{ShadowDepth}", $"0,{SlideOutOffset},{ShadowDepth}"),
|
||||
Transition.Top => ($"0,{-SlideInOffset},{ShadowDepth}", $"0,{-SlideOutOffset},{ShadowDepth}"),
|
||||
Transition.Left => ($"{-SlideInOffset},0,{ShadowDepth}", $"{-SlideOutOffset},0,{ShadowDepth}"),
|
||||
Transition.Right => ($"{SlideInOffset},0,{ShadowDepth}", $"{SlideOutOffset},0,{ShadowDepth}"),
|
||||
_ => (null, null),
|
||||
};
|
||||
|
||||
private void OnHostShowing(TransparentWindow sender, ShowingEventArgs e)
|
||||
{
|
||||
if (e.Transition is Transition transition)
|
||||
{
|
||||
Show(transition);
|
||||
}
|
||||
else
|
||||
{
|
||||
Show();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnHostHiding(TransparentWindow sender, HidingEventArgs e)
|
||||
{
|
||||
// Take a deferral so the host keeps its window visible until our
|
||||
// out-animation has finished, then complete it from HideCompleted.
|
||||
var deferral = e.GetDeferral();
|
||||
|
||||
void OnHideCompleted(object? s, EventArgs args)
|
||||
{
|
||||
HideCompleted -= OnHideCompleted;
|
||||
_abandonPendingHide = null;
|
||||
deferral.Complete();
|
||||
}
|
||||
|
||||
// Let a subsequent Show() cancel this hide cleanly: unsubscribe the
|
||||
// handler so the deferral is never completed (the window stays visible)
|
||||
// rather than firing AppWindow.Hide for an interrupted cycle.
|
||||
_abandonPendingHide = () => HideCompleted -= OnHideCompleted;
|
||||
|
||||
HideCompleted += OnHideCompleted;
|
||||
Hide();
|
||||
}
|
||||
|
||||
private void RebuildDefaultAnimations()
|
||||
{
|
||||
if (!_hasCustomShowAnimations)
|
||||
{
|
||||
_showAnimations = BuildShowAnimations(ShowTransition);
|
||||
}
|
||||
|
||||
if (!_hasCustomHideAnimations)
|
||||
{
|
||||
_hideAnimations = BuildHideAnimations(HideTransition);
|
||||
}
|
||||
}
|
||||
|
||||
private void PinScaleCenter()
|
||||
{
|
||||
var visual = ElementCompositionPreview.GetElementVisual(this);
|
||||
var center = visual.Compositor.CreateExpressionAnimation("Vector3(this.Target.Size.X * 0.5, this.Target.Size.Y * 0.5, 0)");
|
||||
visual.StartAnimation("CenterPoint", center);
|
||||
}
|
||||
|
||||
private static ImplicitAnimationSet BuildShowAnimations(Transition transition)
|
||||
{
|
||||
var animations = new ImplicitAnimationSet();
|
||||
|
||||
if (transition == Transition.None)
|
||||
{
|
||||
// No animation at all.
|
||||
return animations;
|
||||
}
|
||||
|
||||
if (transition == Transition.Pop)
|
||||
{
|
||||
animations.Add(new OpacityAnimation
|
||||
{
|
||||
From = 0,
|
||||
To = 1.0,
|
||||
Duration = TimeSpan.FromMilliseconds(PopFadeShowMs),
|
||||
EasingMode = Microsoft.UI.Xaml.Media.Animation.EasingMode.EaseOut,
|
||||
EasingType = EasingType.Cubic,
|
||||
});
|
||||
|
||||
animations.Add(new ScaleAnimation
|
||||
{
|
||||
From = $"{PopScaleFrom},{PopScaleFrom},1",
|
||||
To = "1,1,1",
|
||||
Duration = TimeSpan.FromMilliseconds(PopScaleShowMs),
|
||||
EasingMode = Microsoft.UI.Xaml.Media.Animation.EasingMode.EaseOut,
|
||||
EasingType = EasingType.Cubic,
|
||||
});
|
||||
|
||||
return animations;
|
||||
}
|
||||
|
||||
var (slideFrom, _) = GetSlideOffsets(transition);
|
||||
|
||||
animations.Add(new OpacityAnimation
|
||||
{
|
||||
From = 0,
|
||||
To = 1.0,
|
||||
Duration = TimeSpan.FromMilliseconds(200),
|
||||
EasingMode = Microsoft.UI.Xaml.Media.Animation.EasingMode.EaseOut,
|
||||
EasingType = EasingType.Cubic,
|
||||
});
|
||||
|
||||
animations.Add(new TranslationAnimation
|
||||
{
|
||||
From = slideFrom,
|
||||
To = $"0,0,{ShadowDepth}",
|
||||
Duration = TimeSpan.FromMilliseconds(250),
|
||||
EasingMode = Microsoft.UI.Xaml.Media.Animation.EasingMode.EaseOut,
|
||||
EasingType = EasingType.Cubic,
|
||||
});
|
||||
|
||||
return animations;
|
||||
}
|
||||
|
||||
private static ImplicitAnimationSet BuildHideAnimations(Transition transition)
|
||||
{
|
||||
var animations = new ImplicitAnimationSet();
|
||||
|
||||
if (transition == Transition.None)
|
||||
{
|
||||
// No animation at all.
|
||||
return animations;
|
||||
}
|
||||
|
||||
if (transition == Transition.Pop)
|
||||
{
|
||||
animations.Add(new OpacityAnimation
|
||||
{
|
||||
From = 1.0,
|
||||
To = 0,
|
||||
Duration = TimeSpan.FromMilliseconds(PopFadeHideMs),
|
||||
EasingMode = Microsoft.UI.Xaml.Media.Animation.EasingMode.EaseIn,
|
||||
EasingType = EasingType.Cubic,
|
||||
});
|
||||
|
||||
animations.Add(new ScaleAnimation
|
||||
{
|
||||
From = "1,1,1",
|
||||
To = $"{PopScaleFrom},{PopScaleFrom},1",
|
||||
Duration = TimeSpan.FromMilliseconds(PopScaleHideMs),
|
||||
EasingMode = Microsoft.UI.Xaml.Media.Animation.EasingMode.EaseIn,
|
||||
EasingType = EasingType.Cubic,
|
||||
});
|
||||
|
||||
return animations;
|
||||
}
|
||||
|
||||
var (_, slideTo) = GetSlideOffsets(transition);
|
||||
|
||||
animations.Add(new OpacityAnimation
|
||||
{
|
||||
From = 1.0,
|
||||
To = 0,
|
||||
Duration = TimeSpan.FromMilliseconds(180),
|
||||
EasingMode = Microsoft.UI.Xaml.Media.Animation.EasingMode.EaseIn,
|
||||
EasingType = EasingType.Cubic,
|
||||
});
|
||||
|
||||
animations.Add(new TranslationAnimation
|
||||
{
|
||||
From = $"0,0,{ShadowDepth}",
|
||||
To = slideTo,
|
||||
Duration = TimeSpan.FromMilliseconds(180),
|
||||
EasingMode = Microsoft.UI.Xaml.Media.Animation.EasingMode.EaseIn,
|
||||
EasingType = EasingType.Cubic,
|
||||
});
|
||||
|
||||
return animations;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// 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.Common.UI.Controls;
|
||||
|
||||
/// <summary>
|
||||
/// A show or hide transition a surface (e.g. <see cref="TransientSurface"/>)
|
||||
/// plays when it is shown or hidden. The directional values describe an edge —
|
||||
/// interpreted as <em>in from</em> that edge on show and <em>out toward</em> it
|
||||
/// on hide — while <see cref="None"/> and <see cref="Pop"/> are non-directional.
|
||||
/// </summary>
|
||||
public enum Transition
|
||||
{
|
||||
/// <summary>No animation; the surface appears and disappears instantly.</summary>
|
||||
None,
|
||||
|
||||
/// <summary>Slide from the left edge (in from on show, out toward on hide).</summary>
|
||||
Left,
|
||||
|
||||
/// <summary>Slide from the top edge (in from on show, out toward on hide).</summary>
|
||||
Top,
|
||||
|
||||
/// <summary>Slide from the right edge (in from on show, out toward on hide).</summary>
|
||||
Right,
|
||||
|
||||
/// <summary>Slide from the bottom edge (in from on show, out toward on hide).</summary>
|
||||
Bottom,
|
||||
|
||||
/// <summary>
|
||||
/// A subtle "pop": a quick fade combined with a small scale between 96% and
|
||||
/// 100% from the surface's center. Stays in place — no slide.
|
||||
/// </summary>
|
||||
Pop,
|
||||
}
|
||||
@@ -1,27 +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.UI.Xaml.Controls;
|
||||
|
||||
namespace Microsoft.PowerToys.Common.UI.Controls;
|
||||
|
||||
/// <summary>
|
||||
/// A floating "card" surface for transient PowerToys overlays (toasts,
|
||||
/// banners, indicators). Provides the PowerToys-standard chrome — 1 px
|
||||
/// border in <c>SurfaceStrokeColorDefaultBrush</c>, 8 px corner radius,
|
||||
/// a <c>ThemeShadow</c>, and an always-active desktop acrylic backdrop —
|
||||
/// via a default <see cref="Microsoft.UI.Xaml.Controls.ControlTemplate"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Lives inside a <see cref="Window.TransparentWindow"/>. Apps that want a
|
||||
/// different look supply their own <c>Style TargetType="TransparentCard"</c>
|
||||
/// in resources — the standard WinUI restyle path.
|
||||
/// </remarks>
|
||||
public sealed partial class TransparentCard : ContentControl
|
||||
{
|
||||
public TransparentCard()
|
||||
{
|
||||
DefaultStyleKey = typeof(TransparentCard);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,6 @@
|
||||
<ResourceDictionary Source="ms-appx:///PowerToys.Common.UI.Controls/Controls/KeyVisual/KeyCharPresenter.xaml" />
|
||||
<ResourceDictionary Source="ms-appx:///PowerToys.Common.UI.Controls/Controls/IsEnabledTextBlock/IsEnabledTextBlock.xaml" />
|
||||
<ResourceDictionary Source="ms-appx:///PowerToys.Common.UI.Controls/Controls/ShortcutWithTextLabelControl/ShortcutWithTextLabelControl.xaml" />
|
||||
<ResourceDictionary Source="ms-appx:///PowerToys.Common.UI.Controls/Controls/TransparentCard/TransparentCard.xaml" />
|
||||
<ResourceDictionary Source="ms-appx:///PowerToys.Common.UI.Controls/Controls/TransientSurface/TransientSurface.xaml" />
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
</ResourceDictionary>
|
||||
|
||||
@@ -9,7 +9,7 @@ using Microsoft.UI.Windowing;
|
||||
using Windows.Graphics;
|
||||
using WinUIEx;
|
||||
|
||||
namespace Microsoft.PowerToys.Common.UI.Controls.Flyout;
|
||||
namespace Microsoft.PowerToys.Common.UI.Controls.Window;
|
||||
|
||||
/// <summary>
|
||||
/// Shared helper for positioning and sizing flyout-style WinUI 3 windows
|
||||
@@ -187,16 +187,13 @@ public static partial class FlyoutWindowHelper
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Two-step move that avoids WM_DPICHANGED double-scaling. First teleports a 1×1
|
||||
/// window into the target display (which may trigger an auto-rescale, but on a 1×1
|
||||
/// rect the effect is invisible). Then sets the real position+size while the window
|
||||
/// is already on the target monitor — no DPI boundary crossing, so WinUI's auto
|
||||
/// handler doesn't fire and overwrite our computed rect.
|
||||
///
|
||||
/// Skips the teleport when the window is already on the target display, since there
|
||||
/// is no boundary to cross.
|
||||
/// Move and resize <paramref name="window"/> to <paramref name="finalRect"/> (absolute
|
||||
/// screen physical-pixel coordinates) on <paramref name="targetDisplay"/>. Performs a
|
||||
/// two-step move that avoids WM_DPICHANGED double-scaling: first a 1×1 teleport into the
|
||||
/// target display (invisible at that size), then the real position+size while the window
|
||||
/// is already on that monitor. Skips the teleport when already on the target display.
|
||||
/// </summary>
|
||||
private static void MoveAndResizeOnDisplay(WindowEx window, DisplayArea targetDisplay, RectInt32 finalRect)
|
||||
public static void MoveAndResizeOnDisplay(WindowEx window, DisplayArea targetDisplay, RectInt32 finalRect)
|
||||
{
|
||||
var currentDisplay = DisplayArea.GetFromWindowId(window.AppWindow.Id, DisplayAreaFallback.Nearest);
|
||||
bool needsTeleport = currentDisplay is null || currentDisplay.DisplayId.Value != targetDisplay.DisplayId.Value;
|
||||
@@ -1,290 +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 System;
|
||||
using System.Runtime.InteropServices;
|
||||
using CommunityToolkit.WinUI;
|
||||
using CommunityToolkit.WinUI.Animations;
|
||||
using Microsoft.UI.Dispatching;
|
||||
using Microsoft.UI.Windowing;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Markup;
|
||||
using WinUIEx;
|
||||
|
||||
namespace Microsoft.PowerToys.Common.UI.Controls.Window;
|
||||
|
||||
/// <summary>
|
||||
/// Reusable transparent host window for transient overlays
|
||||
/// (toasts, banners, indicators) that should not steal foreground.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>The constructor applies all of the boilerplate that PowerToys overlays
|
||||
/// currently hand-roll:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item>Strip the native frame and caption (<c>WS_THICKFRAME</c> etc.).</item>
|
||||
/// <item>Disable the Win11 1-pixel DWM border and corner rounding.</item>
|
||||
/// <item>Mark the window as a tool window so it stays out of the taskbar and Alt-Tab.</item>
|
||||
/// <item>Extend content into the title bar and collapse the title bar.</item>
|
||||
/// </list>
|
||||
/// <para>The visible chrome (acrylic + border + corner radius + shadow) lives
|
||||
/// in a <see cref="TransparentCard"/> that the constructor assigns to
|
||||
/// <see cref="Microsoft.UI.Xaml.Window.Content"/>. Consumers supply their own
|
||||
/// UI via <see cref="InnerContent"/> — which is the XAML default-content slot
|
||||
/// thanks to <see cref="ContentPropertyAttribute"/> — so a derived window can
|
||||
/// be written as <c><common:TransparentWindow><TextBlock/></common:TransparentWindow></c>.</para>
|
||||
/// <para>Transparency is achieved with a <see cref="TransparentTintBackdrop"/>
|
||||
/// system backdrop so the area outside the <see cref="TransparentCard"/> is
|
||||
/// fully see-through. That buffer area is NOT click-through, so consumers
|
||||
/// should keep it as small as possible (just enough to give the card's
|
||||
/// shadow + slide animation room to breathe — roughly 24 px on each side).</para>
|
||||
/// <para><see cref="Show"/> and <see cref="Hide"/> coordinate <c>SW_SHOWNA</c>
|
||||
/// (no-activate), the <see cref="Microsoft.UI.Xaml.UIElement.Visibility"/>
|
||||
/// toggle on the card, and a debounced
|
||||
/// <see cref="Microsoft.UI.Windowing.AppWindow.Hide"/> sized from the longest
|
||||
/// animation in <see cref="HideAnimations"/>. Animations target the card so
|
||||
/// the entire surface (border, acrylic, shadow, inner content) slides as one.</para>
|
||||
/// </remarks>
|
||||
[ContentProperty(Name = nameof(InnerContent))]
|
||||
public partial class TransparentWindow : WinUIEx.WindowEx
|
||||
{
|
||||
private const uint DwmwaColorNone = 0xFFFFFFFE;
|
||||
private const int DwmwaWindowCornerPreference = 33;
|
||||
private const int DwmwaBorderColor = 34;
|
||||
private const int DwmwcpDoNotRound = 1;
|
||||
|
||||
private const int GwlExStyle = -20;
|
||||
private const int WsExToolWindow = 0x00000080;
|
||||
|
||||
private const int SwShowNa = 8;
|
||||
|
||||
private readonly DispatcherQueueTimer _hideCloseTimer = DispatcherQueue.GetForCurrentThread().CreateTimer();
|
||||
private readonly nint _hwnd;
|
||||
private readonly TransparentCard _card;
|
||||
|
||||
private ImplicitAnimationSet _showAnimations;
|
||||
private ImplicitAnimationSet _hideAnimations;
|
||||
|
||||
public TransparentWindow()
|
||||
{
|
||||
AppWindow.Hide();
|
||||
ExtendsContentIntoTitleBar = true;
|
||||
AppWindow.TitleBar.PreferredHeightOption = TitleBarHeightOption.Collapsed;
|
||||
|
||||
_hwnd = WinRT.Interop.WindowNative.GetWindowHandle(this);
|
||||
|
||||
HwndExtensions.ToggleWindowStyle(_hwnd, false, WindowStyle.TiledWindow);
|
||||
|
||||
unsafe
|
||||
{
|
||||
uint borderColor = DwmwaColorNone;
|
||||
_ = DwmSetWindowAttribute(_hwnd, DwmwaBorderColor, &borderColor, sizeof(uint));
|
||||
|
||||
int cornerPref = DwmwcpDoNotRound;
|
||||
_ = DwmSetWindowAttribute(_hwnd, DwmwaWindowCornerPreference, &cornerPref, sizeof(int));
|
||||
}
|
||||
|
||||
ApplyExStyleBit(WsExToolWindow, true);
|
||||
|
||||
_showAnimations = BuildDefaultShowAnimations();
|
||||
_hideAnimations = BuildDefaultHideAnimations();
|
||||
|
||||
_card = new TransparentCard();
|
||||
Content = _card;
|
||||
|
||||
SystemBackdrop = new TransparentTintBackdrop();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the <see cref="TransparentCard"/> that provides the window's
|
||||
/// visible chrome (acrylic + border + shadow). Consumers can configure
|
||||
/// its layout (e.g. <c>HorizontalAlignment</c>, <c>VerticalAlignment</c>,
|
||||
/// <c>MaxWidth</c>, <c>Margin</c>) to position the card inside the
|
||||
/// window, or apply a custom <c>Style</c> to change its look.
|
||||
/// </summary>
|
||||
public TransparentCard Card => _card;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the visual hosted inside the window's
|
||||
/// <see cref="TransparentCard"/>. This is the XAML default-content slot:
|
||||
/// child elements declared between the opening and closing
|
||||
/// <c>TransparentWindow</c> tags in a derived .xaml are routed here.
|
||||
/// </summary>
|
||||
public object? InnerContent
|
||||
{
|
||||
get => _card.Content;
|
||||
set => _card.Content = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the animations played against
|
||||
/// <see cref="Microsoft.UI.Xaml.Window.Content"/> when <see cref="Show"/>
|
||||
/// flips it to <see cref="Visibility.Visible"/>. Defaults to a 200 ms
|
||||
/// fade-in plus a 250 ms slide-up of 24 px.
|
||||
/// </summary>
|
||||
public ImplicitAnimationSet ShowAnimations
|
||||
{
|
||||
get => _showAnimations;
|
||||
set => _showAnimations = value ?? new ImplicitAnimationSet();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the animations played against
|
||||
/// <see cref="Microsoft.UI.Xaml.Window.Content"/> when <see cref="Hide"/>
|
||||
/// flips it to <see cref="Visibility.Collapsed"/>. Defaults to a 180 ms
|
||||
/// fade-out plus a 180 ms slide-down of 12 px.
|
||||
/// </summary>
|
||||
public ImplicitAnimationSet HideAnimations
|
||||
{
|
||||
get => _hideAnimations;
|
||||
set => _hideAnimations = value ?? new ImplicitAnimationSet();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shows the window without activation (<c>SW_SHOWNA</c>) and flips
|
||||
/// <see cref="Microsoft.UI.Xaml.Window.Content"/> to
|
||||
/// <see cref="Visibility.Visible"/> so <see cref="ShowAnimations"/> plays.
|
||||
/// Repeated calls reset the content to its hidden pose first so the show
|
||||
/// animation re-triggers cleanly. Any pending hide is cancelled.
|
||||
/// </summary>
|
||||
public void Show()
|
||||
{
|
||||
DispatcherQueue.TryEnqueue(
|
||||
DispatcherQueuePriority.Low,
|
||||
() =>
|
||||
{
|
||||
_hideCloseTimer.Stop();
|
||||
|
||||
if (Content is UIElement content)
|
||||
{
|
||||
// Re-apply each call so swapping animation collections at
|
||||
// runtime takes effect on the next show/hide cycle.
|
||||
Implicit.SetShowAnimations(content, _showAnimations);
|
||||
Implicit.SetHideAnimations(content, _hideAnimations);
|
||||
|
||||
// Reset to the hidden pose so the show animation always
|
||||
// animates from the configured starting frame.
|
||||
content.Visibility = Visibility.Collapsed;
|
||||
}
|
||||
|
||||
_ = ShowWindow(_hwnd, SwShowNa);
|
||||
|
||||
if (Content is UIElement c2)
|
||||
{
|
||||
c2.Visibility = Visibility.Visible;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Flips <see cref="Microsoft.UI.Xaml.Window.Content"/> to
|
||||
/// <see cref="Visibility.Collapsed"/> so <see cref="HideAnimations"/>
|
||||
/// plays, then hides the underlying
|
||||
/// <see cref="Microsoft.UI.Windowing.AppWindow"/> once the longest
|
||||
/// animation in <see cref="HideAnimations"/> (delay + duration) has
|
||||
/// completed.
|
||||
/// </summary>
|
||||
public void Hide()
|
||||
{
|
||||
DispatcherQueue.TryEnqueue(
|
||||
DispatcherQueuePriority.Low,
|
||||
() =>
|
||||
{
|
||||
if (Content is UIElement content)
|
||||
{
|
||||
content.Visibility = Visibility.Collapsed;
|
||||
}
|
||||
|
||||
_hideCloseTimer.Debounce(
|
||||
AppWindow.Hide,
|
||||
interval: GetAnimationSetTotalDuration(_hideAnimations),
|
||||
immediate: false);
|
||||
});
|
||||
}
|
||||
|
||||
private static TimeSpan GetAnimationSetTotalDuration(ImplicitAnimationSet set)
|
||||
{
|
||||
TimeSpan longest = TimeSpan.Zero;
|
||||
foreach (var animation in set)
|
||||
{
|
||||
if (animation is Animation anim)
|
||||
{
|
||||
var total = (anim.Delay ?? TimeSpan.Zero) + (anim.Duration ?? TimeSpan.Zero);
|
||||
if (total > longest)
|
||||
{
|
||||
longest = total;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return longest;
|
||||
}
|
||||
|
||||
private void ApplyExStyleBit(int bit, bool set)
|
||||
{
|
||||
if (_hwnd == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
nint exStyle = GetWindowLongPtr(_hwnd, GwlExStyle);
|
||||
nint updated = set ? exStyle | bit : exStyle & ~(nint)bit;
|
||||
if (updated != exStyle)
|
||||
{
|
||||
_ = SetWindowLongPtr(_hwnd, GwlExStyle, updated);
|
||||
}
|
||||
}
|
||||
|
||||
private static ImplicitAnimationSet BuildDefaultShowAnimations() => new()
|
||||
{
|
||||
new OpacityAnimation
|
||||
{
|
||||
From = 0,
|
||||
To = 1.0,
|
||||
Duration = TimeSpan.FromMilliseconds(200),
|
||||
EasingMode = Microsoft.UI.Xaml.Media.Animation.EasingMode.EaseOut,
|
||||
EasingType = EasingType.Cubic,
|
||||
},
|
||||
new TranslationAnimation
|
||||
{
|
||||
From = "0,24,32",
|
||||
To = "0,0,32",
|
||||
Duration = TimeSpan.FromMilliseconds(250),
|
||||
EasingMode = Microsoft.UI.Xaml.Media.Animation.EasingMode.EaseOut,
|
||||
EasingType = EasingType.Cubic,
|
||||
},
|
||||
};
|
||||
|
||||
private static ImplicitAnimationSet BuildDefaultHideAnimations() => new()
|
||||
{
|
||||
new OpacityAnimation
|
||||
{
|
||||
From = 1.0,
|
||||
To = 0,
|
||||
Duration = TimeSpan.FromMilliseconds(180),
|
||||
EasingMode = Microsoft.UI.Xaml.Media.Animation.EasingMode.EaseIn,
|
||||
EasingType = EasingType.Cubic,
|
||||
},
|
||||
new TranslationAnimation
|
||||
{
|
||||
From = "0,0,32",
|
||||
To = "0,12,32",
|
||||
Duration = TimeSpan.FromMilliseconds(180),
|
||||
EasingMode = Microsoft.UI.Xaml.Media.Animation.EasingMode.EaseIn,
|
||||
EasingType = EasingType.Cubic,
|
||||
},
|
||||
};
|
||||
|
||||
[LibraryImport("user32.dll", EntryPoint = "GetWindowLongPtrW")]
|
||||
private static partial nint GetWindowLongPtr(nint hWnd, int nIndex);
|
||||
|
||||
[LibraryImport("user32.dll", EntryPoint = "SetWindowLongPtrW")]
|
||||
private static partial nint SetWindowLongPtr(nint hWnd, int nIndex, nint dwNewLong);
|
||||
|
||||
[LibraryImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static partial bool ShowWindow(nint hWnd, int nCmdShow);
|
||||
|
||||
[LibraryImport("dwmapi.dll")]
|
||||
private static unsafe partial int DwmSetWindowAttribute(nint hwnd, int dwAttribute, void* pvAttribute, int cbAttribute);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// 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;
|
||||
using System.Threading;
|
||||
using Deferral = global::Windows.Foundation.Deferral;
|
||||
|
||||
namespace Microsoft.PowerToys.Common.UI.Controls.Window;
|
||||
|
||||
/// <summary>
|
||||
/// Data for <see cref="TransparentWindow.Hiding"/>. Supports deferrals so an
|
||||
/// animated surface can keep the window visible until its out-animation has
|
||||
/// finished. If no handler takes a deferral, the window hides immediately.
|
||||
/// </summary>
|
||||
public sealed class HidingEventArgs : EventArgs
|
||||
{
|
||||
private int _outstanding;
|
||||
private bool _raised;
|
||||
private Action? _continuation;
|
||||
|
||||
/// <summary>
|
||||
/// Requests that the window stay visible until the returned deferral is
|
||||
/// completed. Call <see cref="Deferral.Complete"/> once the out-animation
|
||||
/// has finished.
|
||||
/// </summary>
|
||||
/// <returns>A deferral that must be completed to allow the window to hide.</returns>
|
||||
public Deferral GetDeferral()
|
||||
{
|
||||
Interlocked.Increment(ref _outstanding);
|
||||
return new Deferral(OnDeferralCompleted);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called by the window after raising the event to register what should run
|
||||
/// once every outstanding deferral has completed (or immediately if none
|
||||
/// were taken).
|
||||
/// </summary>
|
||||
internal void RunWhenComplete(Action continuation)
|
||||
{
|
||||
_continuation = continuation;
|
||||
_raised = true;
|
||||
TryComplete();
|
||||
}
|
||||
|
||||
private void OnDeferralCompleted()
|
||||
{
|
||||
Interlocked.Decrement(ref _outstanding);
|
||||
TryComplete();
|
||||
}
|
||||
|
||||
private void TryComplete()
|
||||
{
|
||||
if (_raised && Volatile.Read(ref _outstanding) == 0)
|
||||
{
|
||||
var continuation = _continuation;
|
||||
_continuation = null;
|
||||
continuation?.Invoke();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// 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;
|
||||
|
||||
namespace Microsoft.PowerToys.Common.UI.Controls.Window;
|
||||
|
||||
/// <summary>
|
||||
/// Data for <see cref="TransparentWindow.Showing"/>. Carries the transition the
|
||||
/// content should play, or <see langword="null"/> to let the content use its own
|
||||
/// configured show transition.
|
||||
/// </summary>
|
||||
public sealed class ShowingEventArgs : EventArgs
|
||||
{
|
||||
public ShowingEventArgs(Transition? transition)
|
||||
{
|
||||
Transition = transition;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the transition the content should play, or <see langword="null"/> to
|
||||
/// use the content's own configured show transition.
|
||||
/// </summary>
|
||||
public Transition? Transition { get; }
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
// 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 Microsoft.UI.Dispatching;
|
||||
using Microsoft.UI.Windowing;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Input;
|
||||
using Windows.Foundation;
|
||||
using WinUIEx;
|
||||
|
||||
namespace Microsoft.PowerToys.Common.UI.Controls.Window;
|
||||
|
||||
/// <summary>
|
||||
/// Reusable transparent host window for transient overlays
|
||||
/// (toasts, banners, indicators) that should not steal foreground.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>The constructor applies all of the boilerplate that PowerToys overlays
|
||||
/// currently hand-roll:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item>Strip the native frame and caption (<c>WS_THICKFRAME</c> etc.).</item>
|
||||
/// <item>Disable the Win11 1-pixel DWM border and corner rounding.</item>
|
||||
/// <item>Mark the window as a tool window so it stays out of the taskbar and Alt-Tab.</item>
|
||||
/// <item>Extend content into the title bar and collapse the title bar.</item>
|
||||
/// <item>Apply a <see cref="TransparentTintBackdrop"/> so the HWND is fully
|
||||
/// see-through and the visible chrome can be drawn by the content.</item>
|
||||
/// </list>
|
||||
/// <para>This window is intentionally animation-agnostic: it does not own any
|
||||
/// chrome or motion. Consumers supply their own content (typically a
|
||||
/// <see cref="TransientSurface"/>) which draws the acrylic, border, corners and
|
||||
/// shadow, and animates itself. <see cref="Show()"/> and <see cref="Hide"/>
|
||||
/// coordinate <c>SW_SHOWNA</c> (no-activate) with the
|
||||
/// <see cref="Showing"/> / <see cref="Hiding"/> events: a content surface
|
||||
/// subscribes to those (e.g. via <see cref="TransientSurface.SubscribeTo"/>)
|
||||
/// and plays its in/out animation. The <see cref="Hiding"/> event supports
|
||||
/// deferrals, so the underlying
|
||||
/// <see cref="Microsoft.UI.Windowing.AppWindow.Hide"/> is delayed until the
|
||||
/// content has finished animating out. With no listener the window simply shows
|
||||
/// or hides immediately.</para>
|
||||
/// <para><b>Multiple surfaces.</b> More than one <see cref="TransientSurface"/>
|
||||
/// may host on the same window by each calling
|
||||
/// <see cref="TransientSurface.SubscribeTo"/>. The <see cref="Showing"/> and
|
||||
/// <see cref="Hiding"/> events are simply raised for every subscriber, and
|
||||
/// because <see cref="HidingEventArgs"/> aggregates deferrals the underlying
|
||||
/// window is hidden only after <em>all</em> surfaces have finished animating
|
||||
/// out. To let each surface play its own distinct transition, call the
|
||||
/// parameterless <see cref="Show()"/> (so every surface uses its configured
|
||||
/// <c>ShowTransition</c>/<c>HideTransition</c>); the <see cref="Show(Transition)"/>
|
||||
/// overload instead broadcasts a single transition to all surfaces. Sizing the
|
||||
/// window and positioning each surface within it remain the consumer's
|
||||
/// responsibility (this window owns no layout).</para>
|
||||
/// </remarks>
|
||||
public partial class TransparentWindow : WinUIEx.WindowEx
|
||||
{
|
||||
private const uint DwmwaColorNone = 0xFFFFFFFE;
|
||||
private const int DwmwaWindowCornerPreference = 33;
|
||||
private const int DwmwaBorderColor = 34;
|
||||
private const int DwmwcpDoNotRound = 1;
|
||||
|
||||
private const int GwlExStyle = -20;
|
||||
private const int WsExToolWindow = 0x00000080;
|
||||
|
||||
private const int SwShowNa = 8;
|
||||
|
||||
private readonly nint _hwnd;
|
||||
|
||||
private bool _inputHooked;
|
||||
private bool _seenActivated;
|
||||
|
||||
public TransparentWindow()
|
||||
{
|
||||
AppWindow.Hide();
|
||||
ExtendsContentIntoTitleBar = true;
|
||||
AppWindow.TitleBar.PreferredHeightOption = TitleBarHeightOption.Collapsed;
|
||||
|
||||
_hwnd = WinRT.Interop.WindowNative.GetWindowHandle(this);
|
||||
|
||||
HwndExtensions.ToggleWindowStyle(_hwnd, false, WindowStyle.TiledWindow);
|
||||
|
||||
unsafe
|
||||
{
|
||||
uint borderColor = DwmwaColorNone;
|
||||
_ = DwmSetWindowAttribute(_hwnd, DwmwaBorderColor, &borderColor, sizeof(uint));
|
||||
|
||||
int cornerPref = DwmwcpDoNotRound;
|
||||
_ = DwmSetWindowAttribute(_hwnd, DwmwaWindowCornerPreference, &cornerPref, sizeof(int));
|
||||
}
|
||||
|
||||
ApplyExStyleBit(WsExToolWindow, true);
|
||||
|
||||
SystemBackdrop = new TransparentTintBackdrop();
|
||||
|
||||
Activated += OnActivatedForDismiss;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether pressing <c>Esc</c> while the
|
||||
/// window content has keyboard focus dismisses the window (<see cref="Hide"/>).
|
||||
/// Defaults to <see langword="false"/>. The window is shown without
|
||||
/// activation, so the consumer must activate it for its content to receive
|
||||
/// keyboard input.
|
||||
/// </summary>
|
||||
public bool DismissOnEscape { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the window dismisses itself
|
||||
/// (<see cref="Hide"/>) when it loses focus (is deactivated), i.e. light
|
||||
/// dismiss. Defaults to <see langword="false"/>. Only takes effect after the
|
||||
/// window has been activated at least once since the last <see cref="Show()"/>,
|
||||
/// so the transient deactivation that can occur during the show sequence does
|
||||
/// not dismiss it prematurely. The window is shown without activation, so the
|
||||
/// consumer must activate it for this to apply.
|
||||
/// </summary>
|
||||
public bool DismissOnFocusLost { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Raised (without activation) when <see cref="Show()"/> makes the window
|
||||
/// visible. A content surface subscribes to this to play its in-animation,
|
||||
/// using <see cref="ShowingEventArgs.Transition"/>.
|
||||
/// </summary>
|
||||
public event TypedEventHandler<TransparentWindow, ShowingEventArgs>? Showing;
|
||||
|
||||
/// <summary>
|
||||
/// Raised when <see cref="Hide"/> begins dismissing the window. A content
|
||||
/// surface subscribes to this to play its out-animation, taking a deferral
|
||||
/// (<see cref="HidingEventArgs.GetDeferral"/>) so the underlying window stays
|
||||
/// visible until the animation completes.
|
||||
/// </summary>
|
||||
public event TypedEventHandler<TransparentWindow, HidingEventArgs>? Hiding;
|
||||
|
||||
/// <summary>
|
||||
/// Shows the window without activation (<c>SW_SHOWNA</c>) and raises
|
||||
/// <see cref="Showing"/> without a transition, so subscribed content animates
|
||||
/// in using its own configured show transition.
|
||||
/// </summary>
|
||||
public void Show() => RaiseShow(null);
|
||||
|
||||
/// <summary>
|
||||
/// Shows the window without activation (<c>SW_SHOWNA</c>) and raises
|
||||
/// <see cref="Showing"/> so subscribed content animates in using
|
||||
/// <paramref name="transition"/>, overriding its configured show transition.
|
||||
/// </summary>
|
||||
/// <param name="transition">The transition the content should play.</param>
|
||||
public void Show(Transition transition) => RaiseShow(transition);
|
||||
|
||||
private void RaiseShow(Transition? transition)
|
||||
{
|
||||
DispatcherQueue.TryEnqueue(
|
||||
DispatcherQueuePriority.Low,
|
||||
() =>
|
||||
{
|
||||
_seenActivated = false;
|
||||
EnsureInputHooks();
|
||||
_ = ShowWindow(_hwnd, SwShowNa);
|
||||
Showing?.Invoke(this, new ShowingEventArgs(transition));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raises <see cref="Hiding"/> so subscribed content animates out, then hides
|
||||
/// the underlying <see cref="Microsoft.UI.Windowing.AppWindow"/> once every
|
||||
/// deferral taken by a handler has completed (immediately if none were taken).
|
||||
/// </summary>
|
||||
public void Hide()
|
||||
{
|
||||
DispatcherQueue.TryEnqueue(
|
||||
DispatcherQueuePriority.Low,
|
||||
() =>
|
||||
{
|
||||
var args = new HidingEventArgs();
|
||||
Hiding?.Invoke(this, args);
|
||||
args.RunWhenComplete(AppWindow.Hide);
|
||||
});
|
||||
}
|
||||
|
||||
private void OnActivatedForDismiss(object sender, WindowActivatedEventArgs args)
|
||||
{
|
||||
if (args.WindowActivationState == WindowActivationState.Deactivated)
|
||||
{
|
||||
if (DismissOnFocusLost && _seenActivated)
|
||||
{
|
||||
Hide();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
_seenActivated = true;
|
||||
}
|
||||
|
||||
private void EnsureInputHooks()
|
||||
{
|
||||
if (_inputHooked || Content is not UIElement element)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
element.KeyDown += OnContentKeyDown;
|
||||
_inputHooked = true;
|
||||
}
|
||||
|
||||
private void OnContentKeyDown(object sender, KeyRoutedEventArgs e)
|
||||
{
|
||||
if (DismissOnEscape && e.Key == global::Windows.System.VirtualKey.Escape)
|
||||
{
|
||||
e.Handled = true;
|
||||
Hide();
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyExStyleBit(int bit, bool set)
|
||||
{
|
||||
if (_hwnd == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
nint exStyle = GetWindowLongPtr(_hwnd, GwlExStyle);
|
||||
nint updated = set ? exStyle | bit : exStyle & ~(nint)bit;
|
||||
if (updated != exStyle)
|
||||
{
|
||||
_ = SetWindowLongPtr(_hwnd, GwlExStyle, updated);
|
||||
}
|
||||
}
|
||||
|
||||
[LibraryImport("user32.dll", EntryPoint = "GetWindowLongPtrW")]
|
||||
private static partial nint GetWindowLongPtr(nint hWnd, int nIndex);
|
||||
|
||||
[LibraryImport("user32.dll", EntryPoint = "SetWindowLongPtrW")]
|
||||
private static partial nint SetWindowLongPtr(nint hWnd, int nIndex, nint dwNewLong);
|
||||
|
||||
[LibraryImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static partial bool ShowWindow(nint hWnd, int nCmdShow);
|
||||
|
||||
[LibraryImport("dwmapi.dll")]
|
||||
private static unsafe partial int DwmSetWindowAttribute(nint hwnd, int dwAttribute, void* pvAttribute, int cbAttribute);
|
||||
}
|
||||
@@ -1,49 +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.
|
||||
|
||||
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}";
|
||||
}
|
||||
@@ -1,89 +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 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;
|
||||
}
|
||||
}
|
||||
@@ -1,131 +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 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;
|
||||
}
|
||||
}
|
||||
@@ -1,31 +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.
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,52 +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.
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,17 +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.
|
||||
|
||||
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";
|
||||
}
|
||||
}
|
||||
@@ -1,390 +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 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";
|
||||
}
|
||||
}
|
||||
@@ -1,14 +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.
|
||||
|
||||
namespace Microsoft.PowerToys.UITest.Next;
|
||||
|
||||
/// <summary>WinUI NavigationViewItem surfaces as ControlType.ListItem.</summary>
|
||||
public class NavigationViewItem : Element
|
||||
{
|
||||
public NavigationViewItem()
|
||||
{
|
||||
TargetControlType = "ListItem";
|
||||
}
|
||||
}
|
||||
@@ -1,14 +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.
|
||||
|
||||
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";
|
||||
}
|
||||
}
|
||||
@@ -1,31 +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.
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,41 +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 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;
|
||||
}
|
||||
}
|
||||
@@ -1,17 +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.
|
||||
|
||||
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";
|
||||
}
|
||||
}
|
||||
@@ -1,20 +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.
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -1,46 +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.
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +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.
|
||||
|
||||
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";
|
||||
}
|
||||
}
|
||||
@@ -1,32 +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.
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +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.
|
||||
|
||||
namespace Microsoft.PowerToys.UITest.Next;
|
||||
|
||||
public class Window : Element
|
||||
{
|
||||
public Window()
|
||||
{
|
||||
TargetControlType = "Window";
|
||||
}
|
||||
}
|
||||
@@ -1,71 +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 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,40 +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.
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,204 +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 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;
|
||||
}
|
||||
@@ -1,207 +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.
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,104 +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 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;
|
||||
}
|
||||
}
|
||||
@@ -1,191 +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 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,237 +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 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,421 +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 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);
|
||||
}
|
||||
@@ -1,384 +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 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;
|
||||
}
|
||||
}
|
||||
@@ -1,138 +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 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));
|
||||
}
|
||||
}
|
||||
@@ -1,56 +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>
|
||||
<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>
|
||||
@@ -1,476 +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.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);
|
||||
}
|
||||
}
|
||||
@@ -1,401 +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 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);
|
||||
}
|
||||
}
|
||||
@@ -1,342 +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 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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,189 +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 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),
|
||||
};
|
||||
}
|
||||
@@ -1,155 +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 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,30 +30,12 @@ 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 $@"{prefix}\{ExecutableName}";
|
||||
return $@"\..\..\..\{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);
|
||||
return $@"\..\..\..\{SubDirectory}\{ExecutableName}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -362,72 +362,14 @@ 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",
|
||||
|
||||
// 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,
|
||||
Verb = "runas",
|
||||
};
|
||||
|
||||
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()
|
||||
|
||||
@@ -32,6 +32,17 @@ namespace EnvironmentVariables
|
||||
var loader = ResourceLoaderInstance.ResourceLoader;
|
||||
var title = App.GetService<IElevationHelper>().IsElevated ? loader.GetString("WindowAdminTitle") : loader.GetString("WindowTitle");
|
||||
|
||||
// The WinUI TitleBar control reads the owning window's title (AppWindow.Title) during a
|
||||
// deferred layout pass. If the native window title is empty at that instant, the windowing
|
||||
// layer can fault while resolving it and terminate the process. ResourceLoader.GetString
|
||||
// returns an empty string when the resource map can't be resolved at runtime, which would
|
||||
// leave the title empty here, so fall back to a non-empty product name to keep the native
|
||||
// window title populated.
|
||||
if (string.IsNullOrEmpty(title))
|
||||
{
|
||||
title = "Environment Variables";
|
||||
}
|
||||
|
||||
Title = title;
|
||||
titleBar.Title = title;
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 433 B After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 433 B After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 328 B After Width: | Height: | Size: 2.3 KiB |
@@ -25,6 +25,15 @@ namespace FileLocksmithUI
|
||||
|
||||
var loader = ResourceLoaderInstance.ResourceLoader;
|
||||
var title = isElevated ? loader.GetString("AppAdminTitle") : loader.GetString("AppTitle");
|
||||
|
||||
// Guard against an empty title: ResourceLoader.GetString returns "" when the resource
|
||||
// map can't be resolved, and an empty native window title can fault the WinUI TitleBar
|
||||
// control while it reads AppWindow.Title during a deferred layout pass.
|
||||
if (string.IsNullOrEmpty(title))
|
||||
{
|
||||
title = "File Locksmith";
|
||||
}
|
||||
|
||||
Title = title;
|
||||
titleBar.Title = title;
|
||||
}
|
||||
|
||||
@@ -373,6 +373,13 @@ static int g_overlayRenderedH = 0;
|
||||
// Always On Top (WindowCornerUtils::CornersRadius).
|
||||
static int CornerRadiusForWindow(HWND hwnd)
|
||||
{
|
||||
// Remote sessions draw square windows even on Win11, yet still report DWMWCP_DEFAULT. Match the
|
||||
// window: a remote session gets square (radius 0) so the overlay border doesn't round off the corner.
|
||||
if (GetSystemMetrics(SM_REMOTESESSION))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
int pref = 0; // DWMWCP_DEFAULT
|
||||
if (DwmGetWindowAttribute(hwnd, DWMWA_WINDOW_CORNER_PREFERENCE, &pref, sizeof(pref)) != S_OK)
|
||||
{
|
||||
@@ -1352,6 +1359,7 @@ static void HandleDragMove(POINT pt)
|
||||
RECT maxRect;
|
||||
GetWindowRect(g_dragTarget, &maxRect);
|
||||
int maxW = maxRect.right - maxRect.left;
|
||||
int maxH = maxRect.bottom - maxRect.top;
|
||||
|
||||
ShowWindow(g_dragTarget, SW_RESTORE);
|
||||
|
||||
@@ -1359,9 +1367,12 @@ static void HandleDragMove(POINT pt)
|
||||
int restoredW = g_dragWndRect.right - g_dragWndRect.left;
|
||||
int restoredH = g_dragWndRect.bottom - g_dragWndRect.top;
|
||||
|
||||
float ratio = (maxW > 0) ? static_cast<float>(g_dragStart.x - maxRect.left) / maxW : 0.5f;
|
||||
int newX = g_dragStart.x - static_cast<int>(restoredW * ratio);
|
||||
int newY = g_dragStart.y - (GetSystemMetrics(SM_CYFRAME) + GetSystemMetrics(SM_CYCAPTION) / 2);
|
||||
// Preserve the relative grab position in both axes so the cursor stays
|
||||
// at the same proportional spot within the restored window.
|
||||
float ratioL = (maxW > 0) ? static_cast<float>(g_dragStart.x - maxRect.left) / maxW : 0.5f;
|
||||
float ratioT = (maxH > 0) ? static_cast<float>(g_dragStart.y - maxRect.top) / maxH : 0.5f;
|
||||
int newX = g_dragStart.x - static_cast<int>(restoredW * ratioL);
|
||||
int newY = g_dragStart.y - static_cast<int>(restoredH * ratioT);
|
||||
SetWindowPos(g_dragTarget, nullptr, newX, newY, 0, 0,
|
||||
SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_ASYNCWINDOWPOS);
|
||||
|
||||
|
||||
@@ -33,6 +33,15 @@ namespace Hosts
|
||||
var loader = new ResourceLoader("PowerToys.HostsUILib.pri", "PowerToys.HostsUILib/Resources");
|
||||
|
||||
var title = Host.GetService<IElevationHelper>().IsElevated ? loader.GetString("WindowAdminTitle") : loader.GetString("WindowTitle");
|
||||
|
||||
// Guard against an empty title: ResourceLoader.GetString returns "" when the resource
|
||||
// map can't be resolved, and an empty native window title can fault the WinUI TitleBar
|
||||
// control while it reads AppWindow.Title during a deferred layout pass.
|
||||
if (string.IsNullOrEmpty(title))
|
||||
{
|
||||
title = "Hosts File Editor";
|
||||
}
|
||||
|
||||
Title = title;
|
||||
titleBar.Title = title;
|
||||
|
||||
|
||||
@@ -1,66 +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 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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,47 +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>
|
||||
<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>
|
||||
@@ -1,32 +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.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,539 +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 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);
|
||||
}
|
||||
}
|
||||
@@ -1,64 +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.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,32 +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.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,32 +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.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,32 +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.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
<?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>
|
||||