mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-07-02 00:19:16 +02:00
Compare commits
16 Commits
pt-team/ui
...
powerscrip
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24ce23fa12 | ||
|
|
6d4a1dee6e | ||
|
|
cd327dda07 | ||
|
|
54bd07c08d | ||
|
|
35ccc7658d | ||
|
|
b58b2f1a4c | ||
|
|
0188c1ac69 | ||
|
|
11bda2709b | ||
|
|
a618b2f2f9 | ||
|
|
3cdbca3fa6 | ||
|
|
be711d12bf | ||
|
|
29ca6328f9 | ||
|
|
af2c3c61cd | ||
|
|
a864d421fc | ||
|
|
3331bdf02a | ||
|
|
9ee0c7259b |
6
.github/actions/spell-check/expect.txt
vendored
6
.github/actions/spell-check/expect.txt
vendored
@@ -621,7 +621,9 @@ GETPROPERTYSTOREFLAGS
|
||||
GETSCREENSAVERRUNNING
|
||||
GETSECKEY
|
||||
GETSTICKYKEYS
|
||||
GETTASKBARPOS
|
||||
GETTEXTLENGTH
|
||||
GETWORKAREA
|
||||
gfx
|
||||
GHND
|
||||
gitmodules
|
||||
@@ -1637,6 +1639,7 @@ SETPOWEROFFACTIVE
|
||||
SETRANGE
|
||||
SETREDRAW
|
||||
SETRULES
|
||||
SETAUTOHIDEBAREX
|
||||
SETSCREENSAVEACTIVE
|
||||
SETSTICKYKEYS
|
||||
SETTEXT
|
||||
@@ -1913,6 +1916,7 @@ tracerpt
|
||||
trackbar
|
||||
trafficmanager
|
||||
transicc
|
||||
transitioning
|
||||
TRAYMOUSEMESSAGE
|
||||
triaging
|
||||
trl
|
||||
@@ -2075,8 +2079,6 @@ wifi
|
||||
wikimedia
|
||||
wikipedia
|
||||
winapi
|
||||
winapp
|
||||
winappcli
|
||||
winappsdk
|
||||
windir
|
||||
WINDOWCREATED
|
||||
|
||||
201
.github/skills/ui-tests-migration/LICENSE.txt
vendored
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
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,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>
|
||||
|
||||
@@ -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. -->
|
||||
|
||||
@@ -1589,7 +1589,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">
|
||||
@@ -1107,10 +1095,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 +1126,14 @@
|
||||
<BuildDependency Project="src/modules/fancyzones/editor/FancyZonesEditor/FancyZonesEditor.csproj" />
|
||||
<BuildDependency Project="src/modules/fancyzones/FancyZonesLib/FancyZonesLib.vcxproj" />
|
||||
<BuildDependency Project="src/modules/fancyzones/FancyZonesModuleInterface/FancyZonesModuleInterface.vcxproj" />
|
||||
<BuildDependency Project="src/modules/GrabAndMove/GrabAndMove/GrabAndMove.vcxproj" />
|
||||
<BuildDependency Project="src/modules/GrabAndMove/GrabAndMoveModuleInterface/GrabAndMoveModuleInterface.vcxproj" />
|
||||
<BuildDependency Project="src/modules/imageresizer/dll/ImageResizerExt.vcxproj" />
|
||||
<BuildDependency Project="src/modules/imageresizer/ui/ImageResizerUI.csproj" />
|
||||
<BuildDependency Project="src/modules/keyboardmanager/dll/KeyboardManager.vcxproj" />
|
||||
<BuildDependency Project="src/modules/launcher/Microsoft.Launcher/Microsoft.Launcher.vcxproj" />
|
||||
<BuildDependency Project="src/modules/LightSwitch/LightSwitchModuleInterface/LightSwitchModuleInterface.vcxproj" />
|
||||
<BuildDependency Project="src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj" />
|
||||
<BuildDependency Project="src/modules/GrabAndMove/GrabAndMoveModuleInterface/GrabAndMoveModuleInterface.vcxproj" />
|
||||
<BuildDependency Project="src/modules/GrabAndMove/GrabAndMove/GrabAndMove.vcxproj" />
|
||||
<BuildDependency Project="src/modules/powerrename/dll/PowerRenameExt.vcxproj" />
|
||||
<BuildDependency Project="src/modules/powerrename/lib/PowerRenameLib.vcxproj" />
|
||||
<BuildDependency Project="src/modules/previewpane/Common/PreviewHandlerCommon.csproj" />
|
||||
|
||||
@@ -28,8 +28,8 @@ Create a new test project within your module folder. Ensure the project name fol
|
||||
|
||||
### Step 2: Configure the Project
|
||||
|
||||
1. Set up a `.NET 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\\**"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
<!--
|
||||
|
||||
@@ -38,6 +38,7 @@ namespace ManagedCommon
|
||||
Workspaces,
|
||||
GrabAndMove,
|
||||
ZoomIt,
|
||||
PowerScripts,
|
||||
GeneralSettings,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
17
src/modules/PowerScripts/Directory.Build.props
Normal file
17
src/modules/PowerScripts/Directory.Build.props
Normal file
@@ -0,0 +1,17 @@
|
||||
<Project>
|
||||
<!--
|
||||
PROTOTYPE-ONLY build props for the PowerScripts module.
|
||||
Intentionally does NOT import the repo-root Directory.Build.props so the
|
||||
prototype stays isolated from StyleCop / TreatWarningsAsErrors / Central
|
||||
Package Management while we iterate. Before promoting PowerScripts out of
|
||||
prototype status, delete this file so the projects inherit the standard
|
||||
PowerToys build configuration and analyzers.
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
3
src/modules/PowerScripts/Directory.Build.targets
Normal file
3
src/modules/PowerScripts/Directory.Build.targets
Normal file
@@ -0,0 +1,3 @@
|
||||
<Project>
|
||||
<!-- Empty: stops MSBuild from walking up to the repo-root targets for the prototype. -->
|
||||
</Project>
|
||||
11
src/modules/PowerScripts/Directory.Packages.props
Normal file
11
src/modules/PowerScripts/Directory.Packages.props
Normal file
@@ -0,0 +1,11 @@
|
||||
<Project>
|
||||
<!--
|
||||
PROTOTYPE-ONLY: stops NuGet from discovering the repo-root Directory.Packages.props and
|
||||
disables Central Package Management so the prototype projects can pin their own PackageReference
|
||||
versions in isolation. Remove together with the local Directory.Build.props when promoting the
|
||||
module to the standard PowerToys build.
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,87 @@
|
||||
// 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;
|
||||
using PowerScripts.Core.Manifest;
|
||||
|
||||
namespace PowerScripts.Core.Tests;
|
||||
|
||||
[TestClass]
|
||||
public class ManifestTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void Serializer_RoundTrips_WithCamelCaseEnums()
|
||||
{
|
||||
var manifest = new PowerScriptManifest
|
||||
{
|
||||
Id = "demo",
|
||||
Name = "Demo",
|
||||
Kind = ScriptKind.File,
|
||||
Runtime = ScriptRuntime.PowerShell,
|
||||
Entry = "run.ps1",
|
||||
Input = new ScriptInput { Extensions = { ".png" }, MinFiles = 1, MaxFiles = 0 },
|
||||
Output = new ScriptOutput { Type = ScriptOutputType.SideEffect },
|
||||
Surfaces = { "contextMenu" },
|
||||
};
|
||||
|
||||
var json = ManifestSerializer.Serialize(manifest);
|
||||
StringAssert.Contains(json, "\"kind\": \"file\"");
|
||||
StringAssert.Contains(json, "\"runtime\": \"powerShell\"");
|
||||
|
||||
var back = ManifestSerializer.Deserialize(json);
|
||||
Assert.IsNotNull(back);
|
||||
Assert.AreEqual(ScriptKind.File, back!.Kind);
|
||||
Assert.AreEqual(ScriptOutputType.SideEffect, back.Output!.Type);
|
||||
Assert.AreEqual(".png", back.Input!.Extensions[0]);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Validator_Allows_IdFolderMismatch()
|
||||
{
|
||||
// A script's id is portable and intentionally decoupled from its folder name, so a mismatch
|
||||
// is no longer an error (a downloaded/shared script keeps its id in any folder).
|
||||
var manifest = new PowerScriptManifest { Id = "abc", Name = "x", Entry = "run.ps1" };
|
||||
var errors = ManifestValidator.Validate(manifest, folderName: "different");
|
||||
Assert.AreEqual(0, errors.Count);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Validator_Flags_MissingId()
|
||||
{
|
||||
var manifest = new PowerScriptManifest { Id = string.Empty, Name = "x", Entry = "run.ps1" };
|
||||
var errors = ManifestValidator.Validate(manifest, folderName: "abc");
|
||||
Assert.IsTrue(errors.Any(e => e.Contains("'id' is required")));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Validator_Flags_FileKind_WithoutExtensions()
|
||||
{
|
||||
var manifest = new PowerScriptManifest
|
||||
{
|
||||
Id = "abc",
|
||||
Name = "x",
|
||||
Entry = "run.ps1",
|
||||
Kind = ScriptKind.File,
|
||||
};
|
||||
|
||||
var errors = ManifestValidator.Validate(manifest, "abc");
|
||||
Assert.IsTrue(errors.Any(e => e.Contains("input.extensions")));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Validator_Flags_MaxFiles_LessThanMin()
|
||||
{
|
||||
var manifest = new PowerScriptManifest
|
||||
{
|
||||
Id = "abc",
|
||||
Name = "x",
|
||||
Entry = "run.ps1",
|
||||
Kind = ScriptKind.File,
|
||||
Input = new ScriptInput { Extensions = { ".png" }, MinFiles = 3, MaxFiles = 2 },
|
||||
};
|
||||
|
||||
var errors = ManifestValidator.Validate(manifest, "abc");
|
||||
Assert.IsTrue(errors.Any(e => e.Contains("maxFiles")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<RootNamespace>PowerScripts.Core.Tests</RootNamespace>
|
||||
<AssemblyName>PowerScripts.Core.Tests</AssemblyName>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
|
||||
<PackageReference Include="MSTest.TestAdapter" Version="3.8.3" />
|
||||
<PackageReference Include="MSTest.TestFramework" Version="3.8.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\PowerScripts.Core\PowerScripts.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,166 @@
|
||||
// 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;
|
||||
using PowerScripts.Core.Manifest;
|
||||
using PowerScripts.Core.Registry;
|
||||
|
||||
namespace PowerScripts.Core.Tests;
|
||||
|
||||
[TestClass]
|
||||
public class ScriptRegistryTests
|
||||
{
|
||||
private string _root = string.Empty;
|
||||
|
||||
[TestInitialize]
|
||||
public void Setup()
|
||||
{
|
||||
_root = Path.Combine(Path.GetTempPath(), "powerscripts-tests-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(_root);
|
||||
}
|
||||
|
||||
[TestCleanup]
|
||||
public void Cleanup()
|
||||
{
|
||||
if (Directory.Exists(_root))
|
||||
{
|
||||
Directory.Delete(_root, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
private void WriteScript(string id, string manifestJson, string entryFile = "run.ps1")
|
||||
{
|
||||
var folder = Path.Combine(_root, id);
|
||||
Directory.CreateDirectory(folder);
|
||||
File.WriteAllText(Path.Combine(folder, "manifest.json"), manifestJson);
|
||||
File.WriteAllText(Path.Combine(folder, entryFile), "# noop");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Load_Skips_Invalid_And_Records_Error()
|
||||
{
|
||||
WriteScript("good", """
|
||||
{ "id": "good", "name": "Good", "kind": "system", "entry": "run.ps1" }
|
||||
""");
|
||||
|
||||
// Missing 'id' -> should be rejected.
|
||||
WriteScript("bad", """
|
||||
{ "name": "Bad", "kind": "system", "entry": "run.ps1" }
|
||||
""");
|
||||
|
||||
var registry = new ScriptRegistry(_root);
|
||||
registry.Load();
|
||||
|
||||
Assert.AreEqual(1, registry.Scripts.Count);
|
||||
Assert.AreEqual("good", registry.Scripts[0].Id);
|
||||
Assert.AreEqual(1, registry.Errors.Count);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Load_Allows_IdDecoupledFromFolder()
|
||||
{
|
||||
// The folder name differs from the id; the script is still loaded and keyed by its id.
|
||||
WriteScript("some-folder", """
|
||||
{ "id": "portable.id", "name": "Portable", "kind": "system", "entry": "run.ps1" }
|
||||
""");
|
||||
|
||||
var registry = new ScriptRegistry(_root);
|
||||
registry.Load();
|
||||
|
||||
Assert.AreEqual(1, registry.Scripts.Count);
|
||||
Assert.AreEqual("portable.id", registry.Scripts[0].Id);
|
||||
Assert.AreEqual(0, registry.Errors.Count);
|
||||
Assert.IsNotNull(registry.Get("portable.id"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Load_Rejects_DuplicateIds()
|
||||
{
|
||||
WriteScript("folder-a", """
|
||||
{ "id": "dup", "name": "First", "kind": "system", "entry": "run.ps1" }
|
||||
""");
|
||||
WriteScript("folder-b", """
|
||||
{ "id": "dup", "name": "Second", "kind": "system", "entry": "run.ps1" }
|
||||
""");
|
||||
|
||||
var registry = new ScriptRegistry(_root);
|
||||
registry.Load();
|
||||
|
||||
// Only the first wins; the collision is reported.
|
||||
Assert.AreEqual(1, registry.Scripts.Count);
|
||||
Assert.AreEqual(1, registry.Errors.Count);
|
||||
Assert.IsTrue(registry.Errors[0].Message.Contains("duplicate id"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void FileScriptsFor_Matches_Extension_And_Wildcard()
|
||||
{
|
||||
WriteScript("png-only", """
|
||||
{ "id": "png-only", "name": "PNG", "kind": "file", "entry": "run.ps1",
|
||||
"input": { "extensions": [".png"], "minFiles": 1, "maxFiles": 0 } }
|
||||
""");
|
||||
|
||||
WriteScript("any-file", """
|
||||
{ "id": "any-file", "name": "Any", "kind": "file", "entry": "run.ps1",
|
||||
"input": { "extensions": ["*"], "minFiles": 1, "maxFiles": 0 } }
|
||||
""");
|
||||
|
||||
var registry = new ScriptRegistry(_root);
|
||||
registry.Load();
|
||||
|
||||
var forPng = registry.FileScriptsFor(".PNG").Select(s => s.Id).OrderBy(x => x).ToList();
|
||||
CollectionAssert.AreEqual(new[] { "any-file", "png-only" }, forPng);
|
||||
|
||||
var forTxt = registry.FileScriptsFor(".txt").Select(s => s.Id).ToList();
|
||||
CollectionAssert.AreEqual(new[] { "any-file" }, forTxt);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void FileScriptsForSelection_Respects_MinMax_And_MixedExtensions()
|
||||
{
|
||||
WriteScript("single-png", """
|
||||
{ "id": "single-png", "name": "Single PNG", "kind": "file", "entry": "run.ps1",
|
||||
"input": { "extensions": [".png"], "minFiles": 1, "maxFiles": 1 } }
|
||||
""");
|
||||
|
||||
var registry = new ScriptRegistry(_root);
|
||||
registry.Load();
|
||||
|
||||
// Two files exceeds maxFiles=1.
|
||||
Assert.AreEqual(0, registry.FileScriptsForSelection(new[] { "a.png", "b.png" }).Count());
|
||||
|
||||
// One file is fine.
|
||||
Assert.AreEqual(1, registry.FileScriptsForSelection(new[] { "a.png" }).Count());
|
||||
|
||||
// Mixed extensions: not all match .png.
|
||||
Assert.AreEqual(0, registry.FileScriptsForSelection(new[] { "a.txt" }).Count());
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SystemScripts_Filters_ByKind()
|
||||
{
|
||||
WriteScript("sys", """
|
||||
{ "id": "sys", "name": "Sys", "kind": "system", "entry": "run.ps1" }
|
||||
""");
|
||||
WriteScript("file", """
|
||||
{ "id": "file", "name": "File", "kind": "file", "entry": "run.ps1",
|
||||
"input": { "extensions": ["*"] } }
|
||||
""");
|
||||
|
||||
var registry = new ScriptRegistry(_root);
|
||||
registry.Load();
|
||||
|
||||
var system = registry.SystemScripts.Select(s => s.Id).ToList();
|
||||
CollectionAssert.AreEqual(new[] { "sys" }, system);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Load_EmptyRoot_YieldsNoScripts()
|
||||
{
|
||||
var registry = new ScriptRegistry(_root);
|
||||
registry.Load();
|
||||
Assert.AreEqual(0, registry.Scripts.Count);
|
||||
Assert.AreEqual(0, registry.Errors.Count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// 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;
|
||||
using PowerScripts.Core.Manifest;
|
||||
using PowerScripts.Core.Security;
|
||||
|
||||
namespace PowerScripts.Core.Tests;
|
||||
|
||||
[TestClass]
|
||||
public class SecurityTests
|
||||
{
|
||||
private string _folder = string.Empty;
|
||||
|
||||
[TestInitialize]
|
||||
public void Setup()
|
||||
{
|
||||
_folder = Path.Combine(Path.GetTempPath(), "powerscripts-sec-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(_folder);
|
||||
}
|
||||
|
||||
[TestCleanup]
|
||||
public void Cleanup()
|
||||
{
|
||||
if (Directory.Exists(_folder))
|
||||
{
|
||||
Directory.Delete(_folder, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
private PowerScriptManifest WriteScript(string id, string body, params string[] capabilities)
|
||||
{
|
||||
var entry = "run.ps1";
|
||||
File.WriteAllText(Path.Combine(_folder, entry), body);
|
||||
return new PowerScriptManifest
|
||||
{
|
||||
Id = id,
|
||||
Name = id,
|
||||
Kind = ScriptKind.System,
|
||||
Entry = entry,
|
||||
FolderPath = _folder,
|
||||
Capabilities = capabilities.ToList(),
|
||||
};
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Integrity_IsStable_ForSameContent()
|
||||
{
|
||||
var a = WriteScript("s", "Write-Host hi");
|
||||
var first = ScriptIntegrity.ComputeHash(a);
|
||||
var second = ScriptIntegrity.ComputeHash(a);
|
||||
Assert.AreEqual(first, second);
|
||||
Assert.AreNotEqual(string.Empty, first);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Integrity_Changes_WhenBodyChanges()
|
||||
{
|
||||
var a = WriteScript("s", "Write-Host hi");
|
||||
var before = ScriptIntegrity.ComputeHash(a);
|
||||
|
||||
File.WriteAllText(Path.Combine(_folder, "run.ps1"), "Remove-Item C:\\ -Recurse");
|
||||
var after = ScriptIntegrity.ComputeHash(a);
|
||||
|
||||
Assert.AreNotEqual(before, after);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Integrity_Changes_WhenCapabilitiesChange()
|
||||
{
|
||||
var a = WriteScript("s", "Write-Host hi", "fileRead");
|
||||
var before = ScriptIntegrity.ComputeHash(a);
|
||||
|
||||
var b = WriteScript("s", "Write-Host hi", "fileRead", "process");
|
||||
var after = ScriptIntegrity.ComputeHash(b);
|
||||
|
||||
Assert.AreNotEqual(before, after);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void TrustStore_RoundTrips_And_Enforces_Hash()
|
||||
{
|
||||
var path = Path.Combine(_folder, "trust.json");
|
||||
var manifest = WriteScript("s", "Write-Host hi");
|
||||
var hash = ScriptIntegrity.ComputeHash(manifest);
|
||||
|
||||
var store = new TrustStore(path);
|
||||
Assert.IsFalse(store.IsTrusted("s", hash));
|
||||
|
||||
store.Trust(new TrustRecord { Id = "s", Hash = hash, ApprovedUtc = DateTimeOffset.UtcNow });
|
||||
Assert.IsTrue(store.IsTrusted("s", hash));
|
||||
|
||||
// A different content hash for the same id is NOT trusted (edit invalidates approval).
|
||||
Assert.IsFalse(store.IsTrusted("s", "deadbeef"));
|
||||
|
||||
// Persisted across instances.
|
||||
var reopened = new TrustStore(path);
|
||||
Assert.IsTrue(reopened.IsTrusted("s", hash));
|
||||
|
||||
// Revoke clears it.
|
||||
Assert.IsTrue(reopened.Revoke("s"));
|
||||
Assert.IsFalse(new TrustStore(path).IsTrusted("s", hash));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
// 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 PowerScripts.Core.Manifest;
|
||||
|
||||
namespace PowerScripts.Core.Execution;
|
||||
|
||||
/// <summary>
|
||||
/// The outcome of running a PowerScript.
|
||||
/// </summary>
|
||||
public sealed class ScriptExecutionResult
|
||||
{
|
||||
public int ExitCode { get; init; }
|
||||
|
||||
public bool Succeeded => ExitCode == 0;
|
||||
|
||||
public string StdOut { get; init; } = string.Empty;
|
||||
|
||||
public string StdErr { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs a PowerScript. This is the single execution path shared by every surface (context menu,
|
||||
/// Keyboard Manager, Command Palette, agents) so behavior and security posture stay consistent.
|
||||
///
|
||||
/// Prototype security posture: always runs non-elevated under the invoking user's token, with the
|
||||
/// PowerShell profile disabled and a per-run execution policy of Bypass scoped to the launched
|
||||
/// process only. Signing / capability enforcement is intentionally out of scope for the prototype.
|
||||
/// </summary>
|
||||
public sealed class ScriptExecutor
|
||||
{
|
||||
/// <summary>Environment variable the script can read to get the newline-separated input files.</summary>
|
||||
public const string FilesEnvironmentVariable = "POWERSCRIPTS_FILES";
|
||||
|
||||
public ScriptExecutionResult Execute(
|
||||
PowerScriptManifest manifest,
|
||||
IReadOnlyList<string>? files = null,
|
||||
IReadOnlyDictionary<string, string?>? parameters = null)
|
||||
{
|
||||
if (manifest.Runtime != ScriptRuntime.PowerShell)
|
||||
{
|
||||
throw new NotSupportedException($"Runtime '{manifest.Runtime}' is not supported in the prototype.");
|
||||
}
|
||||
|
||||
if (!File.Exists(manifest.EntryFullPath))
|
||||
{
|
||||
throw new FileNotFoundException("Script entry file not found.", manifest.EntryFullPath);
|
||||
}
|
||||
|
||||
files ??= Array.Empty<string>();
|
||||
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = ResolvePowerShellExecutable(),
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true,
|
||||
WorkingDirectory = manifest.FolderPath,
|
||||
};
|
||||
|
||||
psi.ArgumentList.Add("-NoProfile");
|
||||
psi.ArgumentList.Add("-NonInteractive");
|
||||
psi.ArgumentList.Add("-ExecutionPolicy");
|
||||
psi.ArgumentList.Add("Bypass");
|
||||
psi.ArgumentList.Add("-File");
|
||||
psi.ArgumentList.Add(manifest.EntryFullPath);
|
||||
|
||||
// Files are passed both as a -Files parameter (array binding) and via an environment
|
||||
// variable so scripts can consume whichever is convenient.
|
||||
if (files.Count > 0)
|
||||
{
|
||||
psi.ArgumentList.Add("-Files");
|
||||
foreach (var file in files)
|
||||
{
|
||||
psi.ArgumentList.Add(file);
|
||||
}
|
||||
|
||||
psi.Environment[FilesEnvironmentVariable] = string.Join('\n', files);
|
||||
}
|
||||
|
||||
if (parameters is not null)
|
||||
{
|
||||
foreach (var (name, value) in parameters)
|
||||
{
|
||||
psi.ArgumentList.Add("-" + name);
|
||||
psi.ArgumentList.Add(value ?? string.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
using var process = new Process { StartInfo = psi };
|
||||
process.Start();
|
||||
|
||||
// Read both streams concurrently to avoid pipe deadlock on large output.
|
||||
var stdOutTask = process.StandardOutput.ReadToEndAsync();
|
||||
var stdErrTask = process.StandardError.ReadToEndAsync();
|
||||
process.WaitForExit();
|
||||
|
||||
return new ScriptExecutionResult
|
||||
{
|
||||
ExitCode = process.ExitCode,
|
||||
StdOut = stdOutTask.GetAwaiter().GetResult(),
|
||||
StdErr = stdErrTask.GetAwaiter().GetResult(),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prefers PowerShell 7+ (<c>pwsh</c>); falls back to Windows PowerShell (<c>powershell</c>).
|
||||
/// </summary>
|
||||
private static string ResolvePowerShellExecutable()
|
||||
{
|
||||
return ExistsOnPath("pwsh.exe") ? "pwsh.exe" : "powershell.exe";
|
||||
}
|
||||
|
||||
private static bool ExistsOnPath(string fileName)
|
||||
{
|
||||
var pathVar = Environment.GetEnvironmentVariable("PATH") ?? string.Empty;
|
||||
foreach (var dir in pathVar.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(Path.Combine(dir.Trim(), fileName)))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore malformed PATH entries.
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// 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.Serialization;
|
||||
|
||||
namespace PowerScripts.Core.Manifest;
|
||||
|
||||
/// <summary>
|
||||
/// Centralized JSON options and (de)serialization helpers for PowerScript manifests.
|
||||
/// </summary>
|
||||
public static class ManifestSerializer
|
||||
{
|
||||
public static JsonSerializerOptions Options { get; } = CreateOptions();
|
||||
|
||||
private static JsonSerializerOptions CreateOptions()
|
||||
{
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true,
|
||||
WriteIndented = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
|
||||
options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase));
|
||||
return options;
|
||||
}
|
||||
|
||||
public static PowerScriptManifest? Deserialize(string json) =>
|
||||
JsonSerializer.Deserialize<PowerScriptManifest>(json, Options);
|
||||
|
||||
public static string Serialize(PowerScriptManifest manifest) =>
|
||||
JsonSerializer.Serialize(manifest, Options);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// 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 PowerScripts.Core.Manifest;
|
||||
|
||||
/// <summary>
|
||||
/// Validates a parsed manifest. Returns human-readable errors rather than throwing so the registry
|
||||
/// can skip a single bad script without failing the whole catalogue.
|
||||
///
|
||||
/// A script's <c>id</c> is its portable identity and is intentionally decoupled from the folder it
|
||||
/// happens to live in: this lets a script keep a stable id when it is shared, downloaded from a
|
||||
/// community catalogue, or dropped into a differently-named folder to avoid a local name clash.
|
||||
/// Uniqueness of ids across the catalogue is enforced by the registry, not here.
|
||||
/// </summary>
|
||||
public static class ManifestValidator
|
||||
{
|
||||
public static IReadOnlyList<string> Validate(PowerScriptManifest manifest, string folderName)
|
||||
{
|
||||
_ = folderName;
|
||||
var errors = new List<string>();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(manifest.Id))
|
||||
{
|
||||
errors.Add("'id' is required.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(manifest.Name))
|
||||
{
|
||||
errors.Add("'name' is required.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(manifest.Entry))
|
||||
{
|
||||
errors.Add("'entry' is required.");
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(manifest.FolderPath) && !File.Exists(manifest.EntryFullPath))
|
||||
{
|
||||
errors.Add($"entry script not found: '{manifest.Entry}'.");
|
||||
}
|
||||
|
||||
if (manifest.Kind == ScriptKind.File)
|
||||
{
|
||||
if (manifest.Input is null || manifest.Input.Extensions.Count == 0)
|
||||
{
|
||||
errors.Add("file scripts must declare 'input.extensions'.");
|
||||
}
|
||||
|
||||
if (manifest.Input is { MinFiles: < 1 })
|
||||
{
|
||||
errors.Add("'input.minFiles' must be at least 1.");
|
||||
}
|
||||
|
||||
if (manifest.Input is { MaxFiles: > 0 } input && input.MaxFiles < input.MinFiles)
|
||||
{
|
||||
errors.Add("'input.maxFiles' must be 0 (unbounded) or >= minFiles.");
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
// 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.Serialization;
|
||||
|
||||
namespace PowerScripts.Core.Manifest;
|
||||
|
||||
/// <summary>
|
||||
/// What a PowerScript operates on.
|
||||
/// </summary>
|
||||
public enum ScriptKind
|
||||
{
|
||||
/// <summary>Acts on the PC; no file input. Surfaced via hotkey / Command Palette.</summary>
|
||||
System,
|
||||
|
||||
/// <summary>Acts on one or more input files of a declared type. Surfaced in the right-click menu.</summary>
|
||||
File,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The runtime used to execute a PowerScript. Only PowerShell is supported in the prototype;
|
||||
/// the field exists so Python / Node can be added without a schema break.
|
||||
/// </summary>
|
||||
public enum ScriptRuntime
|
||||
{
|
||||
PowerShell,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The kind of result a file PowerScript produces.
|
||||
/// </summary>
|
||||
public enum ScriptOutputType
|
||||
{
|
||||
None,
|
||||
|
||||
/// <summary>Produces a converted file (e.g. HEIC -> JPG).</summary>
|
||||
ConvertedFile,
|
||||
|
||||
/// <summary>Performs a side effect (e.g. checksum, OCR, strip metadata).</summary>
|
||||
SideEffect,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Declares the file input contract for a <see cref="ScriptKind.File"/> script.
|
||||
/// </summary>
|
||||
public sealed class ScriptInput
|
||||
{
|
||||
/// <summary>File extensions this script accepts (e.g. ".heic"). "*" means any extension.</summary>
|
||||
public List<string> Extensions { get; set; } = new();
|
||||
|
||||
/// <summary>Minimum number of files required.</summary>
|
||||
public int MinFiles { get; set; } = 1;
|
||||
|
||||
/// <summary>Maximum number of files; 0 means unbounded.</summary>
|
||||
public int MaxFiles { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Declares the output contract for a <see cref="ScriptKind.File"/> script.
|
||||
/// </summary>
|
||||
public sealed class ScriptOutput
|
||||
{
|
||||
public ScriptOutputType Type { get; set; } = ScriptOutputType.None;
|
||||
|
||||
/// <summary>For <see cref="ScriptOutputType.ConvertedFile"/>: the produced extension (e.g. ".jpg").</summary>
|
||||
public string? Extension { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A typed, user-editable parameter passed to the script.
|
||||
/// </summary>
|
||||
public sealed class ScriptParameter
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>One of: "string", "int", "bool".</summary>
|
||||
public string Type { get; set; } = "string";
|
||||
|
||||
public string? Default { get; set; }
|
||||
|
||||
public int? Min { get; set; }
|
||||
|
||||
public int? Max { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The on-disk description of a single PowerScript. One script lives in its own folder containing
|
||||
/// a <c>manifest.json</c> (this type) plus the script body referenced by <see cref="Entry"/>.
|
||||
/// </summary>
|
||||
public sealed class PowerScriptManifest
|
||||
{
|
||||
public int SchemaVersion { get; set; } = 1;
|
||||
|
||||
/// <summary>Stable identifier; must match the containing folder name.</summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Optional icon file name, relative to the script folder.</summary>
|
||||
public string? Icon { get; set; }
|
||||
|
||||
/// <summary>Optional author/publisher, shown in the trust prompt (e.g. "contoso" or a GitHub user).</summary>
|
||||
public string? Publisher { get; set; }
|
||||
|
||||
/// <summary>Optional semantic version of the script (e.g. "1.2.0").</summary>
|
||||
public string? Version { get; set; }
|
||||
|
||||
/// <summary>Optional provenance, e.g. the catalogue URL the script was adopted from.</summary>
|
||||
public string? Source { get; set; }
|
||||
|
||||
public ScriptKind Kind { get; set; }
|
||||
|
||||
public ScriptRuntime Runtime { get; set; } = ScriptRuntime.PowerShell;
|
||||
|
||||
/// <summary>Script body file name, relative to the script folder (e.g. "run.ps1").</summary>
|
||||
public string Entry { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>File input contract; required for <see cref="ScriptKind.File"/>.</summary>
|
||||
public ScriptInput? Input { get; set; }
|
||||
|
||||
public ScriptOutput? Output { get; set; }
|
||||
|
||||
public List<ScriptParameter> Parameters { get; set; } = new();
|
||||
|
||||
/// <summary>Where the script appears, e.g. "contextMenu", "keyboardManager", "commandPalette".</summary>
|
||||
public List<string> Surfaces { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Declared capabilities (e.g. "fileRead", "fileWrite", "process"). Doubles as the user-consent
|
||||
/// string and the permission contract an agent / MCP server must respect.
|
||||
/// </summary>
|
||||
public List<string> Capabilities { get; set; } = new();
|
||||
|
||||
/// <summary>Prototype always runs "asInvoker" (non-elevated).</summary>
|
||||
public string Elevation { get; set; } = "asInvoker";
|
||||
|
||||
/// <summary>Absolute path to the folder that contains this manifest. Populated by the registry.</summary>
|
||||
[JsonIgnore]
|
||||
public string FolderPath { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Absolute path to the script body file.</summary>
|
||||
[JsonIgnore]
|
||||
public string EntryFullPath => string.IsNullOrEmpty(FolderPath) ? Entry : Path.Combine(FolderPath, Entry);
|
||||
|
||||
/// <summary>True if this script declares the given surface (case-insensitive).</summary>
|
||||
public bool HasSurface(string surface) =>
|
||||
Surfaces.Any(s => string.Equals(s, surface, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<RootNamespace>PowerScripts.Core</RootNamespace>
|
||||
<AssemblyName>PowerScripts.Core</AssemblyName>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
114
src/modules/PowerScripts/PowerScripts.Core/PowerScriptsPaths.cs
Normal file
114
src/modules/PowerScripts/PowerScripts.Core/PowerScriptsPaths.cs
Normal file
@@ -0,0 +1,114 @@
|
||||
// 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;
|
||||
|
||||
namespace PowerScripts.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Well-known filesystem locations for the PowerScripts module. The scripts root can be overridden
|
||||
/// (explicit path, environment variable, or a persisted user setting) which keeps tests and ad-hoc
|
||||
/// runs hermetic and lets the user point PowerScripts at their own folder from Settings.
|
||||
/// </summary>
|
||||
public static class PowerScriptsPaths
|
||||
{
|
||||
/// <summary>Environment variable that overrides the default scripts root.</summary>
|
||||
public const string RootEnvironmentVariable = "POWERSCRIPTS_ROOT";
|
||||
|
||||
/// <summary>The folder a single script lives in must contain a file with this name.</summary>
|
||||
public const string ManifestFileName = "manifest.json";
|
||||
|
||||
/// <summary>The user-settings file name persisted next to the module data.</summary>
|
||||
public const string ConfigFileName = "config.json";
|
||||
|
||||
/// <summary>
|
||||
/// The module's data directory: <c>%LOCALAPPDATA%\Microsoft\PowerToys\PowerScripts</c>.
|
||||
/// </summary>
|
||||
public static string ModuleDirectory
|
||||
{
|
||||
get
|
||||
{
|
||||
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
return Path.Combine(localAppData, "Microsoft", "PowerToys", "PowerScripts");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The user-settings file that persists the chosen scripts root.</summary>
|
||||
public static string ConfigFilePath => Path.Combine(ModuleDirectory, ConfigFileName);
|
||||
|
||||
/// <summary>The trust store file name (records which script contents the user has approved).</summary>
|
||||
public const string TrustFileName = "trust.json";
|
||||
|
||||
/// <summary>The trust store: which (script id, content hash) pairs the user has approved to run.</summary>
|
||||
public static string TrustFilePath => Path.Combine(ModuleDirectory, TrustFileName);
|
||||
|
||||
/// <summary>
|
||||
/// Default scripts root:
|
||||
/// <c>%LOCALAPPDATA%\Microsoft\PowerToys\PowerScripts\scripts</c>.
|
||||
/// </summary>
|
||||
public static string DefaultScriptsRoot => Path.Combine(ModuleDirectory, "scripts");
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the scripts root, honoring (in order): an explicit path, the environment override,
|
||||
/// the persisted user setting, then the default.
|
||||
/// </summary>
|
||||
public static string ResolveScriptsRoot(string? explicitRoot = null)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(explicitRoot))
|
||||
{
|
||||
return explicitRoot;
|
||||
}
|
||||
|
||||
var fromEnv = Environment.GetEnvironmentVariable(RootEnvironmentVariable);
|
||||
if (!string.IsNullOrWhiteSpace(fromEnv))
|
||||
{
|
||||
return fromEnv;
|
||||
}
|
||||
|
||||
var fromConfig = ReadConfiguredScriptsRoot();
|
||||
return string.IsNullOrWhiteSpace(fromConfig) ? DefaultScriptsRoot : fromConfig;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the user-chosen scripts root from <see cref="ConfigFilePath"/>, or <c>null</c> if it is
|
||||
/// missing, empty, or unreadable.
|
||||
/// </summary>
|
||||
public static string? ReadConfiguredScriptsRoot()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(ConfigFilePath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
using var stream = File.OpenRead(ConfigFilePath);
|
||||
using var document = JsonDocument.Parse(stream);
|
||||
if (document.RootElement.TryGetProperty("scriptsRoot", out var value) &&
|
||||
value.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var root = value.GetString();
|
||||
return string.IsNullOrWhiteSpace(root) ? null : root;
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// A corrupt or unreadable config simply falls back to the default.
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persists the user-chosen scripts root to <see cref="ConfigFilePath"/>. Passing <c>null</c> or
|
||||
/// whitespace clears the override so the default is used again.
|
||||
/// </summary>
|
||||
public static void SaveConfiguredScriptsRoot(string? root)
|
||||
{
|
||||
Directory.CreateDirectory(ModuleDirectory);
|
||||
var normalized = string.IsNullOrWhiteSpace(root) ? string.Empty : root.Trim();
|
||||
var json = JsonSerializer.Serialize(new { scriptsRoot = normalized }, new JsonSerializerOptions { WriteIndented = true });
|
||||
File.WriteAllText(ConfigFilePath, json);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
// 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 PowerScripts.Core.Manifest;
|
||||
|
||||
namespace PowerScripts.Core.Registry;
|
||||
|
||||
/// <summary>
|
||||
/// A manifest that failed to load or validate, kept so the UI can surface problems.
|
||||
/// </summary>
|
||||
public sealed record ScriptLoadError(string FolderPath, string Message);
|
||||
|
||||
/// <summary>
|
||||
/// The single source of truth for installed PowerScripts. Every surface (context menu, Keyboard
|
||||
/// Manager editor, Command Palette, agents) reads from this registry rather than defining scripts
|
||||
/// of its own. The registry only reads the filesystem; it never executes anything.
|
||||
/// </summary>
|
||||
public sealed class ScriptRegistry
|
||||
{
|
||||
private readonly List<PowerScriptManifest> _scripts = new();
|
||||
private readonly List<ScriptLoadError> _errors = new();
|
||||
|
||||
public ScriptRegistry(string? root = null)
|
||||
{
|
||||
Root = PowerScriptsPaths.ResolveScriptsRoot(root);
|
||||
}
|
||||
|
||||
/// <summary>Absolute path to the scanned scripts root.</summary>
|
||||
public string Root { get; }
|
||||
|
||||
public IReadOnlyList<PowerScriptManifest> Scripts => _scripts;
|
||||
|
||||
public IReadOnlyList<ScriptLoadError> Errors => _errors;
|
||||
|
||||
/// <summary>
|
||||
/// Scans <see cref="Root"/> for <c><id>/manifest.json</c> folders, parses and validates each,
|
||||
/// and rebuilds the in-memory catalogue. Bad scripts are recorded in <see cref="Errors"/> and skipped.
|
||||
/// </summary>
|
||||
public void Load()
|
||||
{
|
||||
_scripts.Clear();
|
||||
_errors.Clear();
|
||||
|
||||
if (!Directory.Exists(Root))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var seenIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var folder in Directory.EnumerateDirectories(Root))
|
||||
{
|
||||
var manifestPath = Path.Combine(folder, PowerScriptsPaths.ManifestFileName);
|
||||
if (!File.Exists(manifestPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
PowerScriptManifest? manifest;
|
||||
try
|
||||
{
|
||||
manifest = ManifestSerializer.Deserialize(File.ReadAllText(manifestPath));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_errors.Add(new ScriptLoadError(folder, $"failed to parse manifest.json: {ex.Message}"));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (manifest is null)
|
||||
{
|
||||
_errors.Add(new ScriptLoadError(folder, "manifest.json deserialized to null."));
|
||||
continue;
|
||||
}
|
||||
|
||||
manifest.FolderPath = folder;
|
||||
|
||||
var folderName = new DirectoryInfo(folder).Name;
|
||||
var validationErrors = ManifestValidator.Validate(manifest, folderName);
|
||||
if (validationErrors.Count > 0)
|
||||
{
|
||||
_errors.Add(new ScriptLoadError(folder, string.Join(" ", validationErrors)));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ids are the portable identity and must be unique across the catalogue, since every
|
||||
// surface resolves a script by id. A collision (e.g. two adopted scripts sharing an id)
|
||||
// is reported and the duplicate skipped rather than silently shadowed.
|
||||
if (!seenIds.Add(manifest.Id))
|
||||
{
|
||||
_errors.Add(new ScriptLoadError(folder, $"duplicate id '{manifest.Id}' - already defined by another script; skipped."));
|
||||
continue;
|
||||
}
|
||||
|
||||
_scripts.Add(manifest);
|
||||
}
|
||||
|
||||
_scripts.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
public PowerScriptManifest? Get(string id) =>
|
||||
_scripts.FirstOrDefault(s => string.Equals(s.Id, id, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
/// <summary>System scripts (no file input) — candidates for Keyboard Manager / Command Palette.</summary>
|
||||
public IEnumerable<PowerScriptManifest> SystemScripts =>
|
||||
_scripts.Where(s => s.Kind == ScriptKind.System);
|
||||
|
||||
/// <summary>
|
||||
/// File scripts whose declared input extensions match the given file extension (e.g. ".png").
|
||||
/// A declared extension of "*" matches anything. Used to build the right-click submenu.
|
||||
/// </summary>
|
||||
public IEnumerable<PowerScriptManifest> FileScriptsFor(string extension)
|
||||
{
|
||||
var ext = NormalizeExtension(extension);
|
||||
return _scripts.Where(s =>
|
||||
s.Kind == ScriptKind.File &&
|
||||
s.Input is not null &&
|
||||
s.Input.Extensions.Any(e => MatchesExtension(e, ext)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// File scripts that accept <em>all</em> of the given files (every extension matches and the
|
||||
/// count is within the declared min/max). Used when a multi-file selection is right-clicked.
|
||||
/// </summary>
|
||||
public IEnumerable<PowerScriptManifest> FileScriptsForSelection(IReadOnlyCollection<string> files)
|
||||
{
|
||||
var extensions = files.Select(f => NormalizeExtension(Path.GetExtension(f))).Distinct().ToList();
|
||||
return _scripts.Where(s =>
|
||||
s.Kind == ScriptKind.File &&
|
||||
s.Input is not null &&
|
||||
extensions.All(ext => s.Input.Extensions.Any(e => MatchesExtension(e, ext))) &&
|
||||
files.Count >= s.Input.MinFiles &&
|
||||
(s.Input.MaxFiles == 0 || files.Count <= s.Input.MaxFiles));
|
||||
}
|
||||
|
||||
private static string NormalizeExtension(string extension)
|
||||
{
|
||||
if (string.IsNullOrEmpty(extension))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return extension.StartsWith('.') ? extension.ToLowerInvariant() : "." + extension.ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static bool MatchesExtension(string declared, string normalizedTarget)
|
||||
{
|
||||
if (declared == "*")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return string.Equals(NormalizeExtension(declared), normalizedTarget, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// 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.Security.Cryptography;
|
||||
using System.Text;
|
||||
using PowerScripts.Core.Manifest;
|
||||
|
||||
namespace PowerScripts.Core.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Computes a stable content fingerprint for a script. The fingerprint covers both the executable
|
||||
/// body and the parts of the manifest that define what the script is allowed to do, so that editing
|
||||
/// the script <em>or</em> escalating its declared capabilities invalidates any prior user trust and
|
||||
/// forces a fresh consent prompt (trust-on-first-use).
|
||||
/// </summary>
|
||||
public static class ScriptIntegrity
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the lowercase hex SHA-256 of the script's entry-file bytes combined with its declared
|
||||
/// <c>kind</c> and (sorted) <c>capabilities</c>. Returns an empty string if the entry file is
|
||||
/// missing (an untrusted state that will never match a stored trust record).
|
||||
/// </summary>
|
||||
public static string ComputeHash(PowerScriptManifest manifest)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(manifest);
|
||||
|
||||
var entryPath = manifest.EntryFullPath;
|
||||
if (string.IsNullOrEmpty(entryPath) || !File.Exists(entryPath))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var body = File.ReadAllBytes(entryPath);
|
||||
|
||||
var capabilities = manifest.Capabilities
|
||||
.Select(c => c.Trim().ToLowerInvariant())
|
||||
.Where(c => c.Length > 0)
|
||||
.OrderBy(c => c, StringComparer.Ordinal);
|
||||
|
||||
var declaration = $"\nkind={manifest.Kind}\ncapabilities={string.Join(',', capabilities)}\n";
|
||||
|
||||
using var sha = SHA256.Create();
|
||||
sha.TransformBlock(body, 0, body.Length, null, 0);
|
||||
var declarationBytes = Encoding.UTF8.GetBytes(declaration);
|
||||
sha.TransformFinalBlock(declarationBytes, 0, declarationBytes.Length);
|
||||
|
||||
return Convert.ToHexString(sha.Hash!).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
// 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.Serialization;
|
||||
|
||||
namespace PowerScripts.Core.Security;
|
||||
|
||||
/// <summary>
|
||||
/// A single trust-on-first-use record: the user approved a script id whose content matched
|
||||
/// <see cref="Hash"/>. If the script's content or declared capabilities later change, the recomputed
|
||||
/// hash no longer matches and the user is asked to approve again.
|
||||
/// </summary>
|
||||
public sealed class TrustRecord
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
public string Hash { get; set; } = string.Empty;
|
||||
|
||||
public IReadOnlyList<string> Capabilities { get; set; } = [];
|
||||
|
||||
public string? Source { get; set; }
|
||||
|
||||
public string? Publisher { get; set; }
|
||||
|
||||
public DateTimeOffset ApprovedUtc { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persists which script contents the user has explicitly allowed to run. This is the enforcement
|
||||
/// point behind the manifest's declared <c>capabilities</c>: a script only runs once the user has
|
||||
/// approved its exact current content, and re-approves whenever that content changes.
|
||||
/// </summary>
|
||||
public sealed class TrustStore
|
||||
{
|
||||
private static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
WriteIndented = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
|
||||
private readonly string _path;
|
||||
private readonly Dictionary<string, TrustRecord> _records;
|
||||
|
||||
public TrustStore(string path)
|
||||
{
|
||||
_path = path ?? throw new ArgumentNullException(nameof(path));
|
||||
_records = Load(path);
|
||||
}
|
||||
|
||||
/// <summary>All current trust records.</summary>
|
||||
public IReadOnlyCollection<TrustRecord> Records => _records.Values;
|
||||
|
||||
/// <summary>Returns true if the user has approved this id with exactly this content hash.</summary>
|
||||
public bool IsTrusted(string id, string hash)
|
||||
{
|
||||
if (string.IsNullOrEmpty(id) || string.IsNullOrEmpty(hash))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return _records.TryGetValue(id, out var record)
|
||||
&& string.Equals(record.Hash, hash, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>Records (or updates) approval for an id at the given content hash and persists it.</summary>
|
||||
public void Trust(TrustRecord record)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
_records[record.Id] = record;
|
||||
Save();
|
||||
}
|
||||
|
||||
/// <summary>Removes approval for an id. Returns true if a record was removed.</summary>
|
||||
public bool Revoke(string id)
|
||||
{
|
||||
if (string.IsNullOrEmpty(id) || !_records.Remove(id))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Save();
|
||||
return true;
|
||||
}
|
||||
|
||||
private static Dictionary<string, TrustRecord> Load(string path)
|
||||
{
|
||||
var result = new Dictionary<string, TrustRecord>(StringComparer.OrdinalIgnoreCase);
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
var records = JsonSerializer.Deserialize<List<TrustRecord>>(File.ReadAllText(path), Options);
|
||||
if (records is not null)
|
||||
{
|
||||
foreach (var record in records.Where(r => !string.IsNullOrEmpty(r.Id)))
|
||||
{
|
||||
result[record.Id] = record;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or JsonException or UnauthorizedAccessException)
|
||||
{
|
||||
// A corrupt or unreadable trust file is treated as "nothing trusted" so the user is
|
||||
// simply re-prompted, rather than crashing every surface that runs a script.
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private void Save()
|
||||
{
|
||||
var directory = Path.GetDirectoryName(_path);
|
||||
if (!string.IsNullOrEmpty(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
File.WriteAllText(_path, JsonSerializer.Serialize(_records.Values.ToList(), Options));
|
||||
}
|
||||
}
|
||||
61
src/modules/PowerScripts/PowerScripts.Host/ConsentPrompt.cs
Normal file
61
src/modules/PowerScripts/PowerScripts.Host/ConsentPrompt.cs
Normal file
@@ -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.Runtime.InteropServices;
|
||||
using PowerScripts.Core.Manifest;
|
||||
|
||||
namespace PowerScripts.Host;
|
||||
|
||||
/// <summary>
|
||||
/// Shows the trust-on-first-use consent dialog. Because every surface (context menu, Keyboard
|
||||
/// Manager, agents) funnels through <c>Host run <id></c>, this single prompt is the one place a
|
||||
/// user sees, in plain language, exactly what a script is and what it declares it can do before it
|
||||
/// ever executes. A native top-most MessageBox is used so the prompt is visible even when the Host
|
||||
/// was launched hidden by a surface.
|
||||
/// </summary>
|
||||
internal static class ConsentPrompt
|
||||
{
|
||||
private const uint MB_YESNO = 0x00000004;
|
||||
private const uint MB_ICONWARNING = 0x00000030;
|
||||
private const uint MB_DEFBUTTON2 = 0x00000100;
|
||||
private const uint MB_TOPMOST = 0x00040000;
|
||||
private const uint MB_SETFOREGROUND = 0x00010000;
|
||||
private const int IDYES = 6;
|
||||
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
private static extern int MessageBoxW(IntPtr hWnd, string text, string caption, uint type);
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the user approves running this script. Presents the script's identity,
|
||||
/// provenance and declared capabilities so the decision is informed.
|
||||
/// </summary>
|
||||
public static bool Confirm(PowerScriptManifest manifest)
|
||||
{
|
||||
var capabilities = manifest.Capabilities.Count > 0
|
||||
? string.Join(", ", manifest.Capabilities)
|
||||
: "(none declared)";
|
||||
|
||||
var publisher = string.IsNullOrWhiteSpace(manifest.Publisher) ? "(unknown)" : manifest.Publisher;
|
||||
var source = string.IsNullOrWhiteSpace(manifest.Source) ? "(local)" : manifest.Source;
|
||||
|
||||
var text =
|
||||
$"A PowerScript is about to run for the first time (or its contents changed).\n\n" +
|
||||
$"Name: {manifest.Name}\n" +
|
||||
$"Id: {manifest.Id}\n" +
|
||||
$"Publisher: {publisher}\n" +
|
||||
$"Source: {source}\n" +
|
||||
$"Runtime: {manifest.Runtime}\n" +
|
||||
$"Declares: {capabilities}\n" +
|
||||
$"Script file: {manifest.EntryFullPath}\n\n" +
|
||||
"Only allow scripts you trust. Allow this script to run?";
|
||||
|
||||
var result = MessageBoxW(
|
||||
IntPtr.Zero,
|
||||
text,
|
||||
"PowerScripts — allow this script to run?",
|
||||
MB_YESNO | MB_ICONWARNING | MB_DEFBUTTON2 | MB_TOPMOST | MB_SETFOREGROUND);
|
||||
|
||||
return result == IDYES;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0-windows</TargetFramework>
|
||||
<RootNamespace>PowerScripts.Host</RootNamespace>
|
||||
<AssemblyName>PowerScripts.Host</AssemblyName>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\PowerScripts.Core\PowerScripts.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
481
src/modules/PowerScripts/PowerScripts.Host/Program.cs
Normal file
481
src/modules/PowerScripts/PowerScripts.Host/Program.cs
Normal file
@@ -0,0 +1,481 @@
|
||||
// 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 PowerScripts.Core;
|
||||
using PowerScripts.Core.Execution;
|
||||
using PowerScripts.Core.Manifest;
|
||||
using PowerScripts.Core.Registry;
|
||||
using PowerScripts.Core.Security;
|
||||
|
||||
namespace PowerScripts.Host;
|
||||
|
||||
/// <summary>
|
||||
/// The shared PowerScripts executor / catalogue CLI.
|
||||
///
|
||||
/// This is the single invocation entry point every surface points at:
|
||||
/// - Keyboard Manager maps a hotkey to: PowerScripts.Host.exe run <id>
|
||||
/// - The Explorer context menu invokes: PowerScripts.Host.exe run <id> --files <paths>
|
||||
/// - The KBM editor / agents enumerate via: PowerScripts.Host.exe list --json
|
||||
///
|
||||
/// Usage:
|
||||
/// PowerScripts.Host list [--json] [--root <dir>]
|
||||
/// PowerScripts.Host run <id> [--files <f1> <f2> ...] [--set name=value ...] [--root <dir>]
|
||||
/// </summary>
|
||||
internal static class Program
|
||||
{
|
||||
private static int Main(string[] args)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (args.Length == 0)
|
||||
{
|
||||
PrintUsage();
|
||||
return 1;
|
||||
}
|
||||
|
||||
var (positional, options) = ParseArgs(args.Skip(1).ToArray());
|
||||
var root = options.TryGetValue("root", out var r) ? r.FirstOrDefault() : null;
|
||||
|
||||
var registry = new ScriptRegistry(root);
|
||||
registry.Load();
|
||||
|
||||
return args[0].ToLowerInvariant() switch
|
||||
{
|
||||
"list" => RunList(registry, options.ContainsKey("json")),
|
||||
"run" => RunScript(registry, positional, options),
|
||||
"trust" => RunTrust(registry, positional),
|
||||
"kbm" => RunKbm(registry, positional, options.ContainsKey("json")),
|
||||
"set-extensions" => RunSetExtensions(registry, positional, options),
|
||||
"shell-menu" => RunShellMenu(registry, options),
|
||||
"shell-install" => ShellRegistration.Install(registry, Environment.ProcessPath ?? "PowerScripts.Host.exe"),
|
||||
"shell-uninstall" => ShellRegistration.Uninstall(registry),
|
||||
"-h" or "--help" or "help" => PrintUsage(),
|
||||
_ => Unknown(args[0]),
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"PowerScripts error: {ex.Message}");
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
private static int RunList(ScriptRegistry registry, bool asJson)
|
||||
{
|
||||
if (asJson)
|
||||
{
|
||||
// Structured, permissioned capability list — also the shape the KBM editor picker and
|
||||
// future agents/MCP servers consume.
|
||||
var trustStore = new TrustStore(PowerScriptsPaths.TrustFilePath);
|
||||
var projection = registry.Scripts.Select(s => new
|
||||
{
|
||||
s.Id,
|
||||
s.Name,
|
||||
s.Description,
|
||||
kind = s.Kind.ToString(),
|
||||
runtime = s.Runtime.ToString(),
|
||||
s.Publisher,
|
||||
s.Version,
|
||||
s.Source,
|
||||
s.Surfaces,
|
||||
s.Capabilities,
|
||||
trusted = trustStore.IsTrusted(s.Id, ScriptIntegrity.ComputeHash(s)),
|
||||
input = s.Input,
|
||||
parameters = s.Parameters,
|
||||
});
|
||||
|
||||
Console.WriteLine(JsonSerializer.Serialize(
|
||||
projection,
|
||||
new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
}));
|
||||
return 0;
|
||||
}
|
||||
|
||||
Console.WriteLine($"Scripts root: {registry.Root}");
|
||||
if (registry.Scripts.Count == 0)
|
||||
{
|
||||
Console.WriteLine("(no scripts found)");
|
||||
}
|
||||
|
||||
foreach (var s in registry.Scripts)
|
||||
{
|
||||
Console.WriteLine($" {s.Id,-24} [{s.Kind,-6}] {s.Name}");
|
||||
}
|
||||
|
||||
foreach (var e in registry.Errors)
|
||||
{
|
||||
Console.Error.WriteLine($" ! {e.FolderPath}: {e.Message}");
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static int RunScript(
|
||||
ScriptRegistry registry,
|
||||
IReadOnlyList<string> positional,
|
||||
IReadOnlyDictionary<string, List<string>> options)
|
||||
{
|
||||
if (positional.Count == 0)
|
||||
{
|
||||
Console.Error.WriteLine("run: missing <id>.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var id = positional[0];
|
||||
var manifest = registry.Get(id);
|
||||
if (manifest is null)
|
||||
{
|
||||
Console.Error.WriteLine($"run: no script with id '{id}'. Try 'list'.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var files = options.TryGetValue("files", out var f) ? f : new List<string>();
|
||||
|
||||
// Trust-on-first-use gate. This is the single enforcement point for the manifest's declared
|
||||
// capabilities: a script only runs once the user has approved its exact current content, and
|
||||
// is re-prompted whenever the script body or its declared capabilities change (the content
|
||||
// hash then no longer matches the stored approval).
|
||||
var trustStore = new TrustStore(PowerScriptsPaths.TrustFilePath);
|
||||
var contentHash = ScriptIntegrity.ComputeHash(manifest);
|
||||
if (!trustStore.IsTrusted(id, contentHash))
|
||||
{
|
||||
var nonInteractive = options.ContainsKey("no-consent")
|
||||
|| string.Equals(Environment.GetEnvironmentVariable("POWERSCRIPTS_NO_CONSENT"), "1", StringComparison.Ordinal);
|
||||
|
||||
if (nonInteractive)
|
||||
{
|
||||
Console.Error.WriteLine($"run: script '{id}' is not trusted and consent is disabled; refusing to run. Approve it with 'trust approve {id}'.");
|
||||
return 3;
|
||||
}
|
||||
|
||||
if (!ConsentPrompt.Confirm(manifest))
|
||||
{
|
||||
Console.Error.WriteLine($"run: user declined to trust script '{id}'.");
|
||||
return 3;
|
||||
}
|
||||
|
||||
trustStore.Trust(new TrustRecord
|
||||
{
|
||||
Id = manifest.Id,
|
||||
Hash = contentHash,
|
||||
Capabilities = manifest.Capabilities,
|
||||
Source = manifest.Source,
|
||||
Publisher = manifest.Publisher,
|
||||
ApprovedUtc = DateTimeOffset.UtcNow,
|
||||
});
|
||||
}
|
||||
|
||||
var parameters = new Dictionary<string, string?>();
|
||||
if (options.TryGetValue("set", out var sets))
|
||||
{
|
||||
foreach (var kv in sets)
|
||||
{
|
||||
var idx = kv.IndexOf('=');
|
||||
if (idx <= 0)
|
||||
{
|
||||
Console.Error.WriteLine($"run: --set expects name=value, got '{kv}'.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
parameters[kv[..idx]] = kv[(idx + 1)..];
|
||||
}
|
||||
}
|
||||
|
||||
var executor = new ScriptExecutor();
|
||||
var result = executor.Execute(manifest, files, parameters);
|
||||
|
||||
if (!string.IsNullOrEmpty(result.StdOut))
|
||||
{
|
||||
Console.Out.Write(result.StdOut);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(result.StdErr))
|
||||
{
|
||||
Console.Error.Write(result.StdErr);
|
||||
}
|
||||
|
||||
return result.ExitCode;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manages the trust store — the record of which script contents the user has approved to run.
|
||||
/// trust list show every approved script id + the content hash approved
|
||||
/// trust approve <id> approve the script's current content without running it
|
||||
/// trust revoke <id> forget approval, so the next run re-prompts
|
||||
/// </summary>
|
||||
private static int RunTrust(ScriptRegistry registry, IReadOnlyList<string> positional)
|
||||
{
|
||||
var sub = positional.Count > 0 ? positional[0].ToLowerInvariant() : "list";
|
||||
var trustStore = new TrustStore(PowerScriptsPaths.TrustFilePath);
|
||||
|
||||
switch (sub)
|
||||
{
|
||||
case "list":
|
||||
if (trustStore.Records.Count == 0)
|
||||
{
|
||||
Console.WriteLine("(no scripts trusted yet)");
|
||||
return 0;
|
||||
}
|
||||
|
||||
foreach (var record in trustStore.Records.OrderBy(r => r.Id, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
Console.WriteLine($" {record.Id,-24} {record.Hash[..Math.Min(12, record.Hash.Length)]} approved {record.ApprovedUtc:u}");
|
||||
}
|
||||
|
||||
return 0;
|
||||
|
||||
case "approve":
|
||||
{
|
||||
if (positional.Count < 2)
|
||||
{
|
||||
Console.Error.WriteLine("trust approve: missing <id>.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var manifest = registry.Get(positional[1]);
|
||||
if (manifest is null)
|
||||
{
|
||||
Console.Error.WriteLine($"trust approve: no script with id '{positional[1]}'. Try 'list'.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
trustStore.Trust(new TrustRecord
|
||||
{
|
||||
Id = manifest.Id,
|
||||
Hash = ScriptIntegrity.ComputeHash(manifest),
|
||||
Capabilities = manifest.Capabilities,
|
||||
Source = manifest.Source,
|
||||
Publisher = manifest.Publisher,
|
||||
ApprovedUtc = DateTimeOffset.UtcNow,
|
||||
});
|
||||
|
||||
Console.WriteLine($"trust approve: '{manifest.Id}' approved.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
case "revoke":
|
||||
if (positional.Count < 2)
|
||||
{
|
||||
Console.Error.WriteLine("trust revoke: missing <id>.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (trustStore.Revoke(positional[1]))
|
||||
{
|
||||
Console.WriteLine($"trust revoke: '{positional[1]}' will be re-prompted on next run.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
Console.Error.WriteLine($"trust revoke: '{positional[1]}' was not trusted.");
|
||||
return 1;
|
||||
|
||||
default:
|
||||
Console.Error.WriteLine($"trust: unknown subcommand '{sub}'. Use list | approve <id> | revoke <id>.");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits the Keyboard Manager "Run Program" mapping for a system PowerScript so a user (or the
|
||||
/// future KBM editor picker) can bind a hotkey to it. KBM's existing RunProgram action already
|
||||
/// supports this — no KBM engine change is needed. The app path + args go straight into the
|
||||
/// editor's "Run Program" fields; <c>--json</c> emits the on-disk mapping shape (the user still
|
||||
/// chooses the trigger keys, so <c>originalKeys</c> is left as a placeholder).
|
||||
/// </summary>
|
||||
private static int RunKbm(ScriptRegistry registry, IReadOnlyList<string> positional, bool asJson)
|
||||
{
|
||||
if (positional.Count == 0)
|
||||
{
|
||||
Console.Error.WriteLine("kbm: missing <id>.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var manifest = registry.Get(positional[0]);
|
||||
if (manifest is null)
|
||||
{
|
||||
Console.Error.WriteLine($"kbm: no script with id '{positional[0]}'. Try 'list'.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var hostPath = Environment.ProcessPath ?? "PowerScripts.Host.exe";
|
||||
var programArgs = $"run {manifest.Id}";
|
||||
|
||||
if (asJson)
|
||||
{
|
||||
// Field names match the KBM engine (see common/KeyboardManagerConstants.h /
|
||||
// MappingConfiguration.cpp). Append this to remapShortcutsToRunProgram and set
|
||||
// originalKeys to your chosen trigger (e.g. "162;91;83" for Ctrl+Win+S).
|
||||
var mapping = new Dictionary<string, object>
|
||||
{
|
||||
["originalKeys"] = "<set-your-trigger-keys>",
|
||||
["operationType"] = 1,
|
||||
["runProgramFilePath"] = hostPath,
|
||||
["runProgramArgs"] = programArgs,
|
||||
["runProgramStartInDir"] = string.Empty,
|
||||
["runProgramElevationLevel"] = 0,
|
||||
["runProgramAlreadyRunningAction"] = 0,
|
||||
["runProgramStartWindowType"] = 0,
|
||||
["unicodeText"] = "*Unsupported*",
|
||||
};
|
||||
|
||||
Console.WriteLine(JsonSerializer.Serialize(mapping, new JsonSerializerOptions { WriteIndented = true }));
|
||||
return 0;
|
||||
}
|
||||
|
||||
Console.WriteLine($"PowerScript '{manifest.Id}' ({manifest.Name}) — Keyboard Manager 'Run Program' action:");
|
||||
Console.WriteLine($" Program: {hostPath}");
|
||||
Console.WriteLine($" Arguments: {programArgs}");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("In Keyboard Manager: Remap a shortcut -> action 'Run Program', paste the values above,");
|
||||
Console.WriteLine("then pick the trigger shortcut. (Use 'kbm <id> --json' for the raw mapping object.)");
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits the file scripts that match a right-clicked selection as tab-separated
|
||||
/// <c><id>\t<name></c> lines (one per script). This is the machine-readable feed the
|
||||
/// Windows 11 modern context-menu handler (IExplorerCommand) consumes to build its submenu; a
|
||||
/// line-based format keeps the native handler free of a JSON parser.
|
||||
/// </summary>
|
||||
private static int RunShellMenu(ScriptRegistry registry, IReadOnlyDictionary<string, List<string>> options)
|
||||
{
|
||||
var files = options.TryGetValue("files", out var f) ? f : new List<string>();
|
||||
if (files.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
foreach (var script in registry.FileScriptsForSelection(files))
|
||||
{
|
||||
Console.WriteLine($"{script.Id}\t{script.Name}");
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rewrites a file script's declared input extensions in its manifest.json. This is the write
|
||||
/// side of the Settings "trigger on these file types" editor; the user picks the extensions and
|
||||
/// every surface (context menu, selection matching) then reflects them. System scripts have no
|
||||
/// file input, so they are rejected.
|
||||
/// </summary>
|
||||
private static int RunSetExtensions(
|
||||
ScriptRegistry registry,
|
||||
IReadOnlyList<string> positional,
|
||||
IReadOnlyDictionary<string, List<string>> options)
|
||||
{
|
||||
if (positional.Count == 0)
|
||||
{
|
||||
Console.Error.WriteLine("set-extensions: missing <id>.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var manifest = registry.Get(positional[0]);
|
||||
if (manifest is null)
|
||||
{
|
||||
Console.Error.WriteLine($"set-extensions: no script with id '{positional[0]}'. Try 'list'.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (manifest.Kind != ScriptKind.File)
|
||||
{
|
||||
Console.Error.WriteLine($"set-extensions: '{manifest.Id}' is a {manifest.Kind} script; extensions only apply to File scripts.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var raw = options.TryGetValue("ext", out var values) ? values : new List<string>();
|
||||
var normalized = raw
|
||||
.SelectMany(v => v.Split(new[] { ',', ';', ' ' }, StringSplitOptions.RemoveEmptyEntries))
|
||||
.Select(NormalizeExtension)
|
||||
.Where(e => !string.IsNullOrEmpty(e))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
if (normalized.Count == 0)
|
||||
{
|
||||
Console.Error.WriteLine("set-extensions: at least one extension is required (e.g. --ext .md .txt).");
|
||||
return 1;
|
||||
}
|
||||
|
||||
manifest.Input ??= new ScriptInput();
|
||||
manifest.Input.Extensions = normalized;
|
||||
|
||||
var manifestPath = Path.Combine(manifest.FolderPath, PowerScriptsPaths.ManifestFileName);
|
||||
File.WriteAllText(manifestPath, ManifestSerializer.Serialize(manifest));
|
||||
|
||||
Console.WriteLine($"set-extensions: {manifest.Id} -> [{string.Join(", ", normalized)}]");
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>Normalizes a user-typed extension to lower-case with a leading dot ("md" -> ".md").</summary>
|
||||
private static string NormalizeExtension(string raw)
|
||||
{
|
||||
var e = raw.Trim().ToLowerInvariant();
|
||||
if (string.IsNullOrEmpty(e) || e == "*")
|
||||
{
|
||||
return e;
|
||||
}
|
||||
|
||||
return e.StartsWith('.') ? e : "." + e;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimal parser. Recognizes <c>--name value [value ...]</c> (multi-value, e.g. --files) and
|
||||
/// <c>--flag</c> (no value, e.g. --json). Everything else is positional.
|
||||
/// </summary>
|
||||
private static (List<string> Positional, Dictionary<string, List<string>> Options) ParseArgs(string[] args)
|
||||
{
|
||||
var positional = new List<string>();
|
||||
var options = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
string? current = null;
|
||||
foreach (var arg in args)
|
||||
{
|
||||
if (arg.StartsWith("--", StringComparison.Ordinal))
|
||||
{
|
||||
current = arg[2..];
|
||||
if (!options.ContainsKey(current))
|
||||
{
|
||||
options[current] = new List<string>();
|
||||
}
|
||||
}
|
||||
else if (current is not null)
|
||||
{
|
||||
options[current].Add(arg);
|
||||
}
|
||||
else
|
||||
{
|
||||
positional.Add(arg);
|
||||
}
|
||||
}
|
||||
|
||||
return (positional, options);
|
||||
}
|
||||
|
||||
private static int Unknown(string command)
|
||||
{
|
||||
Console.Error.WriteLine($"Unknown command '{command}'.");
|
||||
PrintUsage();
|
||||
return 1;
|
||||
}
|
||||
|
||||
private static int PrintUsage()
|
||||
{
|
||||
Console.WriteLine("PowerScripts.Host — run and enumerate PowerScripts.");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine(" list [--json] [--root <dir>]");
|
||||
Console.WriteLine(" run <id> [--files <f1> <f2> ...] [--set name=value ...] [--no-consent] [--root <dir>]");
|
||||
Console.WriteLine(" trust list | approve <id> | revoke <id> (manage which scripts are allowed to run)");
|
||||
Console.WriteLine(" kbm <id> [--json] [--root <dir>] (Keyboard Manager 'Run Program' mapping)");
|
||||
Console.WriteLine(" set-extensions <id> --ext <.md .txt ...> (set a file script's trigger extensions)");
|
||||
Console.WriteLine(" shell-menu --files <f1> <f2> ... (tab-separated id/name of matching file scripts)");
|
||||
Console.WriteLine(" shell-install [--root <dir>] (register the Explorer right-click submenu)");
|
||||
Console.WriteLine(" shell-uninstall [--root <dir>] (remove the Explorer right-click submenu)");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
134
src/modules/PowerScripts/PowerScripts.Host/ShellRegistration.cs
Normal file
134
src/modules/PowerScripts/PowerScripts.Host/ShellRegistration.cs
Normal file
@@ -0,0 +1,134 @@
|
||||
// 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.Win32;
|
||||
using PowerScripts.Core.Manifest;
|
||||
using PowerScripts.Core.Registry;
|
||||
|
||||
namespace PowerScripts.Host;
|
||||
|
||||
/// <summary>
|
||||
/// Registers / unregisters the Explorer right-click "PowerScript" cascading submenu for file
|
||||
/// PowerScripts. For each file extension declared by a script, it writes a per-user shell verb under
|
||||
/// <c>HKCU\Software\Classes\SystemFileAssociations\<ext>\shell\PowerScripts</c> whose nested
|
||||
/// sub-verbs (one per matching script) invoke <c>PowerScripts.Host.exe run <id> --files "%1"</c>.
|
||||
///
|
||||
/// This is the prototype's context-menu surface: it needs no COM DLL and is driven entirely by the
|
||||
/// script registry, so right-click works immediately and reflects the installed scripts. The
|
||||
/// PowerScripts module (runner) calls <c>shell-install</c> on enable and <c>shell-uninstall</c> on
|
||||
/// disable.
|
||||
/// </summary>
|
||||
internal static class ShellRegistration
|
||||
{
|
||||
private const string RootVerb = "PowerScripts";
|
||||
private const string MenuLabel = "PowerScript";
|
||||
private const string ClassesRoot = @"Software\Classes\SystemFileAssociations";
|
||||
|
||||
/// <summary>Marker value so uninstall only removes keys this tool created.</summary>
|
||||
private const string OwnerMarkerName = "PowerScriptsOwned";
|
||||
|
||||
public static int Install(ScriptRegistry registry, string hostExePath)
|
||||
{
|
||||
// Group file scripts by each declared extension (skip the "*" wildcard for the static menu).
|
||||
var byExtension = new Dictionary<string, List<PowerScriptManifest>>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var script in registry.Scripts.Where(s => s.Kind == ScriptKind.File && s.Input is not null))
|
||||
{
|
||||
foreach (var rawExt in script.Input!.Extensions)
|
||||
{
|
||||
if (rawExt == "*")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var ext = rawExt.StartsWith('.') ? rawExt : "." + rawExt;
|
||||
if (!byExtension.TryGetValue(ext, out var list))
|
||||
{
|
||||
list = new List<PowerScriptManifest>();
|
||||
byExtension[ext] = list;
|
||||
}
|
||||
|
||||
list.Add(script);
|
||||
}
|
||||
}
|
||||
|
||||
if (byExtension.Count == 0)
|
||||
{
|
||||
Console.WriteLine("shell-install: no file scripts with concrete extensions to register.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
foreach (var (ext, scripts) in byExtension)
|
||||
{
|
||||
RemoveVerbForExtension(ext);
|
||||
|
||||
var verbPath = $@"{ClassesRoot}\{ext}\shell\{RootVerb}";
|
||||
using var verbKey = Microsoft.Win32.Registry.CurrentUser.CreateSubKey(verbPath)!;
|
||||
verbKey.SetValue("MUIVerb", MenuLabel);
|
||||
verbKey.SetValue(OwnerMarkerName, 1, RegistryValueKind.DWord);
|
||||
|
||||
// Presence of "SubCommands" makes Explorer render the nested \shell verbs as a submenu.
|
||||
verbKey.SetValue("SubCommands", string.Empty);
|
||||
|
||||
using var subShell = verbKey.CreateSubKey("shell")!;
|
||||
foreach (var script in scripts)
|
||||
{
|
||||
using var item = subShell.CreateSubKey(script.Id)!;
|
||||
item.SetValue("MUIVerb", script.Name);
|
||||
using var command = item.CreateSubKey("command")!;
|
||||
command.SetValue(null, $"\"{hostExePath}\" run {script.Id} --files \"%1\"");
|
||||
}
|
||||
|
||||
Console.WriteLine($" registered {scripts.Count} script(s) for {ext}");
|
||||
}
|
||||
|
||||
Console.WriteLine($"shell-install: done ({byExtension.Count} extension(s)).");
|
||||
return 0;
|
||||
}
|
||||
|
||||
public static int Uninstall(ScriptRegistry registry)
|
||||
{
|
||||
// Remove for every extension currently declared, plus best-effort sweep is unnecessary since
|
||||
// we only ever create owned keys.
|
||||
var extensions = registry.Scripts
|
||||
.Where(s => s.Kind == ScriptKind.File && s.Input is not null)
|
||||
.SelectMany(s => s.Input!.Extensions)
|
||||
.Where(e => e != "*")
|
||||
.Select(e => e.StartsWith('.') ? e : "." + e)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var ext in extensions)
|
||||
{
|
||||
RemoveVerbForExtension(ext);
|
||||
}
|
||||
|
||||
Console.WriteLine("shell-uninstall: done.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static void RemoveVerbForExtension(string ext)
|
||||
{
|
||||
var verbParent = $@"{ClassesRoot}\{ext}\shell";
|
||||
using var shellKey = Microsoft.Win32.Registry.CurrentUser.OpenSubKey(verbParent, writable: true);
|
||||
if (shellKey is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Only delete the verb if we own it.
|
||||
using (var verbKey = shellKey.OpenSubKey(RootVerb))
|
||||
{
|
||||
if (verbKey is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (verbKey.GetValue(OwnerMarkerName) is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
shellKey.DeleteSubKeyTree(RootVerb, throwOnMissingSubKey: false);
|
||||
}
|
||||
}
|
||||
9
src/modules/PowerScripts/PowerScriptsContextMenu/.gitignore
vendored
Normal file
9
src/modules/PowerScripts/PowerScriptsContextMenu/.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
# Native handler build artifacts
|
||||
*.dll
|
||||
*.lib
|
||||
*.exp
|
||||
*.obj
|
||||
*.pdb
|
||||
*.ilk
|
||||
# Host publish output used by register.ps1
|
||||
hostpublish/
|
||||
@@ -0,0 +1,51 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Package
|
||||
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
|
||||
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
|
||||
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
|
||||
xmlns:desktop4="http://schemas.microsoft.com/appx/manifest/desktop/windows10/4"
|
||||
xmlns:desktop5="http://schemas.microsoft.com/appx/manifest/desktop/windows10/5"
|
||||
xmlns:uap10="http://schemas.microsoft.com/appx/manifest/uap/windows10/10"
|
||||
xmlns:com="http://schemas.microsoft.com/appx/manifest/com/windows10"
|
||||
IgnorableNamespaces="uap rescap desktop4 desktop5 uap10 com">
|
||||
<Identity Name="Microsoft.PowerToys.PowerScriptsContextMenu" ProcessorArchitecture="neutral" Publisher="CN=Microsoft Corporation, O=Microsoft Corporation, L=Redmond, S=Washington, C=US" Version="1.0.0.0" />
|
||||
<Properties>
|
||||
<DisplayName>PowerToys PowerScripts Context Menu</DisplayName>
|
||||
<PublisherDisplayName>Microsoft</PublisherDisplayName>
|
||||
<Logo>Assets\storelogo.png</Logo>
|
||||
</Properties>
|
||||
<Resources>
|
||||
<Resource Language="en-us" />
|
||||
</Resources>
|
||||
<Dependencies>
|
||||
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.18950.0" MaxVersionTested="10.0.19000.0" />
|
||||
</Dependencies>
|
||||
<Capabilities>
|
||||
<rescap:Capability Name="runFullTrust" />
|
||||
<rescap:Capability Name="unvirtualizedResources" />
|
||||
</Capabilities>
|
||||
<Applications>
|
||||
<Application Id="PowerScriptsContextMenu" Executable="PowerScripts.Host.exe" uap10:TrustLevel="mediumIL" uap10:RuntimeBehavior="win32App">
|
||||
<uap:VisualElements AppListEntry="none" DisplayName="PowerToys PowerScripts Context Menu" Description="PowerScripts context menu handler" BackgroundColor="transparent" Square150x150Logo="Assets\Square150x150Logo.png" Square44x44Logo="Assets\Square44x44Logo.png">
|
||||
<uap:DefaultTile Wide310x150Logo="Assets\Wide310x150Logo.png" Square310x310Logo="Assets\LargeTile.png" Square71x71Logo="Assets\SmallTile.png"></uap:DefaultTile>
|
||||
<uap:SplashScreen Image="Assets\SplashScreen.png" />
|
||||
</uap:VisualElements>
|
||||
<Extensions>
|
||||
<desktop4:Extension Category="windows.fileExplorerContextMenus">
|
||||
<desktop4:FileExplorerContextMenus>
|
||||
<desktop5:ItemType Type="*">
|
||||
<desktop5:Verb Id="PowerScriptsCommand" Clsid="9FF7C126-9562-4F16-A6FB-9622B26E0D62" />
|
||||
</desktop5:ItemType>
|
||||
</desktop4:FileExplorerContextMenus>
|
||||
</desktop4:Extension>
|
||||
<com:Extension Category="windows.comServer" uap10:RuntimeBehavior="packagedClassicApp">
|
||||
<com:ComServer>
|
||||
<com:SurrogateServer DisplayName="PowerScripts context menu verb handler">
|
||||
<com:Class Id="9FF7C126-9562-4F16-A6FB-9622B26E0D62" Path="PowerToys.PowerScriptsContextMenu.dll" ThreadingModel="STA" />
|
||||
</com:SurrogateServer>
|
||||
</com:ComServer>
|
||||
</com:Extension>
|
||||
</Extensions>
|
||||
</Application>
|
||||
</Applications>
|
||||
</Package>
|
||||
15
src/modules/PowerScripts/PowerScriptsContextMenu/build.cmd
Normal file
15
src/modules/PowerScripts/PowerScriptsContextMenu/build.cmd
Normal file
@@ -0,0 +1,15 @@
|
||||
@echo off
|
||||
rem Builds the PowerScripts Windows 11 context-menu handler DLL (self-contained, no PowerToys deps).
|
||||
setlocal
|
||||
set "VCVARS=C:\Program Files\Microsoft Visual Studio\18\Enterprise\VC\Auxiliary\Build\vcvars64.bat"
|
||||
if not exist "%VCVARS%" (
|
||||
echo Could not find vcvars64.bat at "%VCVARS%". Edit build.cmd to point at your VS install.
|
||||
exit /b 1
|
||||
)
|
||||
call "%VCVARS%" >nul || exit /b 1
|
||||
cd /d "%~dp0"
|
||||
cl /nologo /std:c++17 /EHsc /O2 /MT /DUNICODE /D_UNICODE /LD dllmain.cpp ^
|
||||
/Fe:PowerToys.PowerScriptsContextMenu.dll ^
|
||||
/link /DEF:dll.def shlwapi.lib runtimeobject.lib ole32.lib || exit /b 1
|
||||
echo Built PowerToys.PowerScriptsContextMenu.dll
|
||||
endlocal
|
||||
4
src/modules/PowerScripts/PowerScriptsContextMenu/dll.def
Normal file
4
src/modules/PowerScripts/PowerScriptsContextMenu/dll.def
Normal file
@@ -0,0 +1,4 @@
|
||||
EXPORTS
|
||||
DllCanUnloadNow PRIVATE
|
||||
DllGetClassObject PRIVATE
|
||||
DllGetActivationFactory PRIVATE
|
||||
388
src/modules/PowerScripts/PowerScriptsContextMenu/dllmain.cpp
Normal file
388
src/modules/PowerScripts/PowerScriptsContextMenu/dllmain.cpp
Normal file
@@ -0,0 +1,388 @@
|
||||
// PowerScripts Windows 11 modern context-menu handler.
|
||||
//
|
||||
// A self-contained IExplorerCommand COM server (no PowerToys common dependencies). It surfaces a
|
||||
// top-level "PowerScript" entry with a dynamic submenu of the file scripts that match the current
|
||||
// selection. The actual matching/running logic lives in PowerScripts.Host.exe (deployed next to
|
||||
// this DLL); the handler is a thin shell that:
|
||||
// * GetState -> runs "Host shell-menu --files <paths>", caches the id/name lines, hides itself
|
||||
// when nothing matches.
|
||||
// * EnumSubCommands -> turns each cached line into a submenu item.
|
||||
// * Invoke (item) -> runs "Host run <id> --files <paths>".
|
||||
|
||||
#include <windows.h>
|
||||
#include <shobjidl_core.h>
|
||||
#include <shlwapi.h>
|
||||
#include <wrl/module.h>
|
||||
#include <wrl/implements.h>
|
||||
#include <wrl/client.h>
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using namespace Microsoft::WRL;
|
||||
|
||||
namespace
|
||||
{
|
||||
HMODULE g_hModule = nullptr;
|
||||
long g_refModule = 0;
|
||||
|
||||
// Full path to PowerScripts.Host.exe, assumed to sit next to this DLL.
|
||||
std::wstring FindHostExe()
|
||||
{
|
||||
wchar_t path[MAX_PATH] = {};
|
||||
GetModuleFileNameW(g_hModule, path, ARRAYSIZE(path));
|
||||
std::wstring dir(path);
|
||||
const size_t slash = dir.find_last_of(L"\\/");
|
||||
if (slash != std::wstring::npos)
|
||||
{
|
||||
dir.erase(slash + 1);
|
||||
}
|
||||
return dir + L"PowerScripts.Host.exe";
|
||||
}
|
||||
|
||||
// Extracts the filesystem paths from a shell selection.
|
||||
std::vector<std::wstring> ExtractPaths(IShellItemArray* selection)
|
||||
{
|
||||
std::vector<std::wstring> result;
|
||||
if (selection == nullptr)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
DWORD count = 0;
|
||||
if (FAILED(selection->GetCount(&count)))
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
for (DWORD i = 0; i < count; ++i)
|
||||
{
|
||||
ComPtr<IShellItem> item;
|
||||
if (FAILED(selection->GetItemAt(i, &item)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
PWSTR pszPath = nullptr;
|
||||
if (SUCCEEDED(item->GetDisplayName(SIGDN_FILESYSPATH, &pszPath)) && pszPath != nullptr)
|
||||
{
|
||||
result.emplace_back(pszPath);
|
||||
CoTaskMemFree(pszPath);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Quotes a single command-line argument.
|
||||
std::wstring Quote(const std::wstring& value)
|
||||
{
|
||||
return L"\"" + value + L"\"";
|
||||
}
|
||||
|
||||
std::wstring BuildFilesArguments(const std::vector<std::wstring>& files)
|
||||
{
|
||||
std::wstring args;
|
||||
for (const auto& file : files)
|
||||
{
|
||||
args += L" " + Quote(file);
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
// Runs a Host command and returns its stdout. Used only for the (small) shell-menu listing.
|
||||
std::wstring RunHostCapture(const std::wstring& arguments)
|
||||
{
|
||||
std::wstring output;
|
||||
|
||||
SECURITY_ATTRIBUTES sa = {};
|
||||
sa.nLength = sizeof(sa);
|
||||
sa.bInheritHandle = TRUE;
|
||||
|
||||
HANDLE readPipe = nullptr;
|
||||
HANDLE writePipe = nullptr;
|
||||
if (!CreatePipe(&readPipe, &writePipe, &sa, 0))
|
||||
{
|
||||
return output;
|
||||
}
|
||||
SetHandleInformation(readPipe, HANDLE_FLAG_INHERIT, 0);
|
||||
|
||||
std::wstring commandLine = Quote(FindHostExe()) + L" " + arguments;
|
||||
|
||||
STARTUPINFOW si = {};
|
||||
si.cb = sizeof(si);
|
||||
si.dwFlags = STARTF_USESTDHANDLES | STARTF_USESHOWWINDOW;
|
||||
si.wShowWindow = SW_HIDE;
|
||||
si.hStdOutput = writePipe;
|
||||
si.hStdError = writePipe;
|
||||
|
||||
PROCESS_INFORMATION pi = {};
|
||||
std::vector<wchar_t> mutableCmd(commandLine.begin(), commandLine.end());
|
||||
mutableCmd.push_back(L'\0');
|
||||
|
||||
if (!CreateProcessW(nullptr, mutableCmd.data(), nullptr, nullptr, TRUE, CREATE_NO_WINDOW, nullptr, nullptr, &si, &pi))
|
||||
{
|
||||
CloseHandle(readPipe);
|
||||
CloseHandle(writePipe);
|
||||
return output;
|
||||
}
|
||||
|
||||
CloseHandle(writePipe);
|
||||
|
||||
char buffer[4096];
|
||||
DWORD read = 0;
|
||||
std::string raw;
|
||||
while (ReadFile(readPipe, buffer, sizeof(buffer), &read, nullptr) && read > 0)
|
||||
{
|
||||
raw.append(buffer, read);
|
||||
}
|
||||
|
||||
CloseHandle(readPipe);
|
||||
WaitForSingleObject(pi.hProcess, 15000);
|
||||
CloseHandle(pi.hProcess);
|
||||
CloseHandle(pi.hThread);
|
||||
|
||||
if (!raw.empty())
|
||||
{
|
||||
const int needed = MultiByteToWideChar(CP_UTF8, 0, raw.c_str(), static_cast<int>(raw.size()), nullptr, 0);
|
||||
if (needed > 0)
|
||||
{
|
||||
output.resize(needed);
|
||||
MultiByteToWideChar(CP_UTF8, 0, raw.c_str(), static_cast<int>(raw.size()), output.data(), needed);
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
// Runs a Host command fire-and-forget (used to actually execute a script).
|
||||
void RunHostDetached(const std::wstring& arguments)
|
||||
{
|
||||
std::wstring commandLine = Quote(FindHostExe()) + L" " + arguments;
|
||||
|
||||
STARTUPINFOW si = {};
|
||||
si.cb = sizeof(si);
|
||||
si.dwFlags = STARTF_USESHOWWINDOW;
|
||||
si.wShowWindow = SW_HIDE;
|
||||
|
||||
PROCESS_INFORMATION pi = {};
|
||||
std::vector<wchar_t> mutableCmd(commandLine.begin(), commandLine.end());
|
||||
mutableCmd.push_back(L'\0');
|
||||
|
||||
if (CreateProcessW(nullptr, mutableCmd.data(), nullptr, nullptr, FALSE, CREATE_NO_WINDOW, nullptr, nullptr, &si, &pi))
|
||||
{
|
||||
CloseHandle(pi.hProcess);
|
||||
CloseHandle(pi.hThread);
|
||||
}
|
||||
}
|
||||
|
||||
struct ScriptEntry
|
||||
{
|
||||
std::wstring Id;
|
||||
std::wstring Name;
|
||||
};
|
||||
|
||||
// Parses "id\tname" lines into entries.
|
||||
std::vector<ScriptEntry> ParseMenu(const std::wstring& text)
|
||||
{
|
||||
std::vector<ScriptEntry> entries;
|
||||
size_t start = 0;
|
||||
while (start < text.size())
|
||||
{
|
||||
size_t end = text.find(L'\n', start);
|
||||
std::wstring line = (end == std::wstring::npos) ? text.substr(start) : text.substr(start, end - start);
|
||||
start = (end == std::wstring::npos) ? text.size() : end + 1;
|
||||
|
||||
if (!line.empty() && line.back() == L'\r')
|
||||
{
|
||||
line.pop_back();
|
||||
}
|
||||
if (line.empty())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
const size_t tab = line.find(L'\t');
|
||||
if (tab == std::wstring::npos)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
ScriptEntry entry;
|
||||
entry.Id = line.substr(0, tab);
|
||||
entry.Name = line.substr(tab + 1);
|
||||
if (!entry.Id.empty())
|
||||
{
|
||||
entries.push_back(std::move(entry));
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
}
|
||||
|
||||
// A single submenu item: "Convert Markdown to Text", etc.
|
||||
class PowerScriptSubCommand : public RuntimeClass<RuntimeClassFlags<ClassicCom>, IExplorerCommand>
|
||||
{
|
||||
public:
|
||||
PowerScriptSubCommand(std::wstring id, std::wstring name, std::vector<std::wstring> files) :
|
||||
m_id(std::move(id)), m_name(std::move(name)), m_files(std::move(files))
|
||||
{
|
||||
}
|
||||
|
||||
IFACEMETHODIMP GetTitle(IShellItemArray*, PWSTR* name) override { return SHStrDupW(m_name.c_str(), name); }
|
||||
IFACEMETHODIMP GetIcon(IShellItemArray*, PWSTR* icon) override { *icon = nullptr; return E_NOTIMPL; }
|
||||
IFACEMETHODIMP GetToolTip(IShellItemArray*, PWSTR* tip) override { *tip = nullptr; return E_NOTIMPL; }
|
||||
IFACEMETHODIMP GetCanonicalName(GUID* guid) override { *guid = GUID_NULL; return S_OK; }
|
||||
IFACEMETHODIMP GetState(IShellItemArray*, BOOL, EXPCMDSTATE* state) override { *state = ECS_ENABLED; return S_OK; }
|
||||
IFACEMETHODIMP GetFlags(EXPCMDFLAGS* flags) override { *flags = ECF_DEFAULT; return S_OK; }
|
||||
IFACEMETHODIMP EnumSubCommands(IEnumExplorerCommand** enumerator) override { *enumerator = nullptr; return E_NOTIMPL; }
|
||||
|
||||
IFACEMETHODIMP Invoke(IShellItemArray* selection, IBindCtx*) override
|
||||
{
|
||||
std::vector<std::wstring> files = m_files;
|
||||
if (files.empty())
|
||||
{
|
||||
files = ExtractPaths(selection);
|
||||
}
|
||||
|
||||
RunHostDetached(L"run " + m_id + L" --files" + BuildFilesArguments(files));
|
||||
return S_OK;
|
||||
}
|
||||
|
||||
private:
|
||||
std::wstring m_id;
|
||||
std::wstring m_name;
|
||||
std::vector<std::wstring> m_files;
|
||||
};
|
||||
|
||||
// IEnumExplorerCommand over the submenu items.
|
||||
class PowerScriptEnum : public RuntimeClass<RuntimeClassFlags<ClassicCom>, IEnumExplorerCommand>
|
||||
{
|
||||
public:
|
||||
explicit PowerScriptEnum(std::vector<ComPtr<IExplorerCommand>> commands) :
|
||||
m_commands(std::move(commands))
|
||||
{
|
||||
}
|
||||
|
||||
IFACEMETHODIMP Next(ULONG count, IExplorerCommand** commands, ULONG* fetched) override
|
||||
{
|
||||
ULONG produced = 0;
|
||||
for (; produced < count && m_index < m_commands.size(); ++produced, ++m_index)
|
||||
{
|
||||
m_commands[m_index].CopyTo(&commands[produced]);
|
||||
}
|
||||
if (fetched != nullptr)
|
||||
{
|
||||
*fetched = produced;
|
||||
}
|
||||
return (produced == count) ? S_OK : S_FALSE;
|
||||
}
|
||||
|
||||
IFACEMETHODIMP Skip(ULONG count) override
|
||||
{
|
||||
m_index += count;
|
||||
return (m_index <= m_commands.size()) ? S_OK : S_FALSE;
|
||||
}
|
||||
|
||||
IFACEMETHODIMP Reset() override
|
||||
{
|
||||
m_index = 0;
|
||||
return S_OK;
|
||||
}
|
||||
|
||||
IFACEMETHODIMP Clone(IEnumExplorerCommand** out) override
|
||||
{
|
||||
*out = nullptr;
|
||||
return E_NOTIMPL;
|
||||
}
|
||||
|
||||
private:
|
||||
std::vector<ComPtr<IExplorerCommand>> m_commands;
|
||||
size_t m_index = 0;
|
||||
};
|
||||
|
||||
// Top-level "PowerScript" command with a dynamic submenu.
|
||||
class __declspec(uuid("9FF7C126-9562-4F16-A6FB-9622B26E0D62")) PowerScriptCommand :
|
||||
public RuntimeClass<RuntimeClassFlags<ClassicCom>, IExplorerCommand, IObjectWithSite>
|
||||
{
|
||||
public:
|
||||
IFACEMETHODIMP GetTitle(IShellItemArray*, PWSTR* name) override { return SHStrDupW(L"PowerScript", name); }
|
||||
IFACEMETHODIMP GetIcon(IShellItemArray*, PWSTR* icon) override { *icon = nullptr; return E_NOTIMPL; }
|
||||
IFACEMETHODIMP GetToolTip(IShellItemArray*, PWSTR* tip) override { *tip = nullptr; return E_NOTIMPL; }
|
||||
IFACEMETHODIMP GetCanonicalName(GUID* guid) override { *guid = GUID_NULL; return S_OK; }
|
||||
|
||||
// Called before EnumSubCommands on the same instance; we use it to compute (and cache) the
|
||||
// matching scripts and to hide the entry when nothing matches.
|
||||
IFACEMETHODIMP GetState(IShellItemArray* selection, BOOL, EXPCMDSTATE* state) override
|
||||
{
|
||||
m_files = ExtractPaths(selection);
|
||||
m_entries.clear();
|
||||
|
||||
if (!m_files.empty())
|
||||
{
|
||||
const std::wstring output = RunHostCapture(L"shell-menu --files" + BuildFilesArguments(m_files));
|
||||
m_entries = ParseMenu(output);
|
||||
}
|
||||
|
||||
*state = m_entries.empty() ? ECS_HIDDEN : ECS_ENABLED;
|
||||
return S_OK;
|
||||
}
|
||||
|
||||
IFACEMETHODIMP GetFlags(EXPCMDFLAGS* flags) override { *flags = ECF_HASSUBCOMMANDS; return S_OK; }
|
||||
|
||||
IFACEMETHODIMP EnumSubCommands(IEnumExplorerCommand** enumerator) override
|
||||
{
|
||||
*enumerator = nullptr;
|
||||
|
||||
std::vector<ComPtr<IExplorerCommand>> commands;
|
||||
for (const auto& entry : m_entries)
|
||||
{
|
||||
commands.push_back(Make<PowerScriptSubCommand>(entry.Id, entry.Name, m_files));
|
||||
}
|
||||
|
||||
auto enumObject = Make<PowerScriptEnum>(std::move(commands));
|
||||
return enumObject.CopyTo(enumerator);
|
||||
}
|
||||
|
||||
IFACEMETHODIMP Invoke(IShellItemArray*, IBindCtx*) override { return S_OK; }
|
||||
|
||||
// IObjectWithSite
|
||||
IFACEMETHODIMP SetSite(IUnknown* site) override { m_site = site; return S_OK; }
|
||||
IFACEMETHODIMP GetSite(REFIID riid, void** ppv) override { return m_site.CopyTo(riid, ppv); }
|
||||
|
||||
private:
|
||||
ComPtr<IUnknown> m_site;
|
||||
std::vector<std::wstring> m_files;
|
||||
std::vector<ScriptEntry> m_entries;
|
||||
};
|
||||
|
||||
CoCreatableClass(PowerScriptCommand);
|
||||
|
||||
STDAPI DllGetActivationFactory(_In_ HSTRING activatableClassId, _COM_Outptr_ IActivationFactory** factory)
|
||||
{
|
||||
return Module<ModuleType::InProc>::GetModule().GetActivationFactory(activatableClassId, factory);
|
||||
}
|
||||
|
||||
STDAPI DllCanUnloadNow()
|
||||
{
|
||||
return (Module<InProc>::GetModule().GetObjectCount() == 0 && g_refModule == 0) ? S_OK : S_FALSE;
|
||||
}
|
||||
|
||||
STDAPI DllGetClassObject(_In_ REFCLSID rclsid, _In_ REFIID riid, _COM_Outptr_ void** ppv)
|
||||
{
|
||||
return Module<InProc>::GetModule().GetClassObject(rclsid, riid, ppv);
|
||||
}
|
||||
|
||||
BOOL APIENTRY DllMain(HMODULE hModule, DWORD reason, LPVOID)
|
||||
{
|
||||
switch (reason)
|
||||
{
|
||||
case DLL_PROCESS_ATTACH:
|
||||
g_hModule = hModule;
|
||||
DisableThreadLibraryCalls(hModule);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Builds and registers the PowerScripts Windows 11 modern context-menu handler as an
|
||||
unsigned sparse (loose-file) MSIX package. Requires Developer Mode.
|
||||
|
||||
.DESCRIPTION
|
||||
1. Builds the native handler DLL (build.cmd).
|
||||
2. Publishes PowerScripts.Host.exe (framework-dependent) next to the DLL.
|
||||
3. Copies the manifest + logo assets into a deploy folder.
|
||||
4. Registers the package in place via Add-AppxPackage -Register.
|
||||
|
||||
Run register.ps1 -Unregister to remove it.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[switch]$Unregister,
|
||||
[ValidateSet('Debug', 'Release')]
|
||||
[string]$Configuration = 'Debug'
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$PackageName = 'Microsoft.PowerToys.PowerScriptsContextMenu'
|
||||
$here = Split-Path -Parent $MyInvocation.MyCommand.Definition
|
||||
$deployDir = Join-Path $env:LOCALAPPDATA 'Microsoft\PowerToys\PowerScriptsContextMenu'
|
||||
|
||||
if ($Unregister)
|
||||
{
|
||||
$pkg = Get-AppxPackage -Name $PackageName -ErrorAction SilentlyContinue
|
||||
if ($pkg)
|
||||
{
|
||||
Remove-AppxPackage -Package $pkg.PackageFullName
|
||||
Write-Host "Unregistered $($pkg.PackageFullName)"
|
||||
}
|
||||
else
|
||||
{
|
||||
Write-Host "Package $PackageName is not registered."
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
Write-Host '== Building handler DLL =='
|
||||
& cmd /c "`"$here\build.cmd`""
|
||||
if ($LASTEXITCODE -ne 0) { throw 'DLL build failed.' }
|
||||
|
||||
Write-Host '== Publishing PowerScripts.Host =='
|
||||
$hostProj = Join-Path $here '..\PowerScripts.Host\PowerScripts.Host.csproj'
|
||||
$hostPublish = Join-Path $here 'hostpublish'
|
||||
& dotnet publish $hostProj -c $Configuration -o $hostPublish --nologo | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Host publish failed.' }
|
||||
|
||||
Write-Host '== Staging deploy folder =='
|
||||
# Re-register cleanly: remove any prior registration before overwriting files.
|
||||
$existing = Get-AppxPackage -Name $PackageName -ErrorAction SilentlyContinue
|
||||
if ($existing) { Remove-AppxPackage -Package $existing.PackageFullName }
|
||||
|
||||
if (Test-Path $deployDir) { Remove-Item $deployDir -Recurse -Force }
|
||||
New-Item -ItemType Directory -Force -Path $deployDir | Out-Null
|
||||
New-Item -ItemType Directory -Force -Path (Join-Path $deployDir 'Assets') | Out-Null
|
||||
|
||||
Copy-Item (Join-Path $here 'PowerToys.PowerScriptsContextMenu.dll') $deployDir -Force
|
||||
Copy-Item (Join-Path $here 'AppxManifest.xml') $deployDir -Force
|
||||
Copy-Item (Join-Path $hostPublish '*') $deployDir -Recurse -Force
|
||||
|
||||
# Reuse the ImageResizer context-menu logo assets for the required tile slots.
|
||||
$assetSrc = Join-Path $here '..\..\..\modules\imageresizer\ImageResizerContextMenu\Assets\ImageResizer'
|
||||
foreach ($asset in 'storelogo.png', 'Square150x150Logo.png', 'Square44x44Logo.png', 'Wide310x150Logo.png', 'LargeTile.png', 'SmallTile.png', 'SplashScreen.png')
|
||||
{
|
||||
Copy-Item (Join-Path $assetSrc $asset) (Join-Path $deployDir 'Assets') -Force
|
||||
}
|
||||
|
||||
Write-Host '== Registering package =='
|
||||
Add-AppxPackage -Register (Join-Path $deployDir 'AppxManifest.xml')
|
||||
|
||||
Write-Host "Registered. Deploy folder: $deployDir"
|
||||
Write-Host 'Right-click a matching file (e.g. a .md) to see the PowerScript submenu (restart Explorer if needed).'
|
||||
165
src/modules/PowerScripts/README.md
Normal file
165
src/modules/PowerScripts/README.md
Normal file
@@ -0,0 +1,165 @@
|
||||
# PowerScripts (prototype)
|
||||
|
||||
> **Status: prototype.** Write a small script once and surface it across PowerToys.
|
||||
> This folder contains the **working core** (manifest schema, registry, shared executor
|
||||
> `PowerScripts.Host.exe`) plus sample scripts, and three **implemented surfaces**:
|
||||
> a Settings module page, the Explorer right-click menu, and the Keyboard Manager editor.
|
||||
|
||||
## Implemented surfaces (prototype)
|
||||
|
||||
| Surface | What it does | How |
|
||||
| --- | --- | --- |
|
||||
| **Settings module** | New "PowerScripts" page in the Settings app that lists installed scripts and has an enable toggle. Enabling/disabling installs/removes the Explorer context-menu entries. | `src/settings-ui/.../Views/PowerScriptsPage.xaml(.cs)` + `PowerScriptsViewModel`; reads `Host.exe list --json`; toggle runs `Host.exe shell-install`/`shell-uninstall`. |
|
||||
| **Explorer right-click** | Right-click a file → "PowerScript" submenu lists scripts whose manifest declares that extension; clicking runs the script on the file. | `Host.exe shell-install` writes `HKCU\Software\Classes\SystemFileAssociations\<ext>\shell\PowerScripts` cascading verbs → `Host.exe run <id> --files "%1"`. |
|
||||
| **Keyboard Manager** | A new "PowerScript" action in the KBM editor; pick a system script and assign it to a hotkey. | `KeyboardManagerEditorUI` action picker saves an ordinary `RunProgram` mapping → `Host.exe run <id>`. |
|
||||
|
||||
### End-to-end demo
|
||||
|
||||
1. **Settings**: open Settings → PowerScripts → see `convert_md_to_txt`, `volume_up`, etc.; toggle on.
|
||||
2. **Context menu**: right-click a `.md` file → PowerScript → "Convert Markdown to Text" → a `.txt` is written next to it.
|
||||
3. **Keyboard Manager**: KBM editor → add mapping → action "PowerScript" → pick "Volume Up" → assign a shortcut.
|
||||
|
||||
|
||||
## The idea
|
||||
|
||||
A **PowerScript** is a script plus a manifest, living in its own folder. Two flavours:
|
||||
|
||||
- **System** (`kind: "system"`) — "do something on my PC". No file input. Triggered by a Keyboard
|
||||
Manager hotkey (and later the Command Palette).
|
||||
- **File** (`kind: "file"`) — "do something with this file". Input is one or more files of declared
|
||||
types. Surfaced in the Explorer right-click menu.
|
||||
|
||||
Every surface is a thin consumer of one **registry** and invokes one **executor** — so a script is
|
||||
authored once and appears everywhere it's declared.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Registry (PowerScripts.Core) ──read──► surfaces:
|
||||
scans <root>/<id>/manifest.json • Explorer context menu (file actions)
|
||||
• Keyboard Manager editor (system actions)
|
||||
• Command Palette / Advanced Paste (later)
|
||||
▲ │ invoke
|
||||
└──────────── all surfaces ────────────────┘
|
||||
▼
|
||||
PowerScripts.Host.exe (executor)
|
||||
list [--json] | run <id> [--files ...] [--set k=v ...]
|
||||
```
|
||||
|
||||
- **`PowerScripts.Core`** — manifest model + JSON (`Manifest/`), validation, registry (`Registry/`),
|
||||
executor (`Execution/`).
|
||||
- **`PowerScripts.Host`** — the CLI every surface points at. `list --json` is the structured catalogue
|
||||
the KBM editor picker and future agents/MCP consume; `run <id>` executes.
|
||||
- **`samples/`** — `system-snapshot` & `volume_up` (system), `sha256-checksum` & `convert_md_to_txt` (file).
|
||||
|
||||
### Scripts root
|
||||
|
||||
`%LOCALAPPDATA%\Microsoft\PowerToys\PowerScripts\scripts\<id>\manifest.json`
|
||||
(override with the `POWERSCRIPTS_ROOT` env var or `--root`).
|
||||
|
||||
## Manifest schema (v1)
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "heic-to-jpg", // must match the folder name
|
||||
"name": "Convert HEIC to JPG",
|
||||
"description": "…",
|
||||
"kind": "file", // "system" | "file"
|
||||
"runtime": "powershell", // prototype: powershell only
|
||||
"entry": "run.ps1",
|
||||
"input": { "extensions": [".heic"], "minFiles": 1, "maxFiles": 0 }, // file kind
|
||||
"output": { "type": "convertedFile", "extension": ".jpg" },
|
||||
"parameters": [ { "name": "quality", "type": "int", "default": "90", "min": 1, "max": 100 } ],
|
||||
"surfaces": ["contextMenu", "keyboardManager"],
|
||||
"capabilities": ["fileWrite"], // consent string + agent permission contract
|
||||
"elevation": "asInvoker" // prototype always runs non-elevated
|
||||
}
|
||||
```
|
||||
|
||||
## Build & run
|
||||
|
||||
```powershell
|
||||
cd src\modules\PowerScripts
|
||||
dotnet build PowerScripts.Host\PowerScripts.Host.csproj -c Debug
|
||||
|
||||
$env:POWERSCRIPTS_ROOT = "$PWD\samples"
|
||||
$exe = "PowerScripts.Host\bin\Debug\net10.0\PowerScripts.Host.exe"
|
||||
& $exe list
|
||||
& $exe run system-snapshot
|
||||
& $exe run sha256-checksum --files C:\some\file.png
|
||||
```
|
||||
|
||||
> The prototype projects are isolated from the repo build via local `Directory.Build.props`,
|
||||
> `Directory.Packages.props` and `nuget.config` (no StyleCop / warnings-as-errors / central package
|
||||
> management; restores from public nuget.org). Delete these three files when promoting the module to
|
||||
> follow standard PowerToys build rules.
|
||||
|
||||
## Tests
|
||||
|
||||
```powershell
|
||||
cd src\modules\PowerScripts
|
||||
dotnet test PowerScripts.Core.Tests\PowerScripts.Core.Tests.csproj
|
||||
```
|
||||
|
||||
`PowerScripts.Core.Tests` (MSTest) covers manifest serialization/validation and the registry
|
||||
(extension + wildcard matching, multi-file selection min/max, kind filtering, invalid-script
|
||||
skipping). 9 tests, all passing.
|
||||
|
||||
## Surface integration plans
|
||||
|
||||
### 1. Keyboard Manager (system actions) — first priority
|
||||
|
||||
KBM already has a `RunProgram` action, so a hotkey → PowerScript works **today**. Get the exact
|
||||
mapping for a system script:
|
||||
|
||||
```powershell
|
||||
& $exe kbm system-snapshot # prints Program path + Arguments for the editor
|
||||
& $exe kbm system-snapshot --json # prints the raw remapShortcutsToRunProgram object
|
||||
```
|
||||
|
||||
Then in Keyboard Manager → *Remap a shortcut* → action **Run Program**, paste the Program path and
|
||||
`run <id>` arguments and choose the trigger keys. The mapping persists as the existing engine shape
|
||||
(verified against `common/KeyboardManagerConstants.h`):
|
||||
|
||||
```json
|
||||
{ "operationType": 1, "runProgramFilePath": "…\\PowerScripts.Host.exe", "runProgramArgs": "run system-snapshot", "unicodeText": "*Unsupported*" }
|
||||
```
|
||||
|
||||
**Prototype goal — pick a PowerScript inside the editor** (instead of typing a path). The editor is
|
||||
**C# WinUI 3** (`PowerToys.KeyboardManagerEditorUI.exe`), a separate process that already reads JSON
|
||||
at runtime, so it can call `Host.exe list --json` to populate a script dropdown. Additive change-list
|
||||
(verified against the current source):
|
||||
|
||||
- `Controls/UnifiedMappingControl.xaml.cs` — the nested `enum ActionType` (KeyOrShortcut, Text,
|
||||
OpenUrl, OpenApp, MouseClick, Disable): add a `PowerScript` value; extend `CurrentActionType`,
|
||||
`SetActionType`, `IsInputComplete`.
|
||||
- `Controls/UnifiedMappingControl.xaml` — add a `ComboBoxItem` (Tag `PowerScript`) to
|
||||
`ActionTypeComboBox` and a `SwitchPresenter` `Case` hosting a script-picker ComboBox.
|
||||
- `Pages/MainPage.xaml.cs` — add a `UnifiedMappingControl.ActionType.PowerScript` arm to the save
|
||||
`switch` (~line 390) that reuses the `SaveProgramMapping` path with
|
||||
`ProgramPath = <PowerScripts.Host.exe>` and `ProgramArgs = "run <id>"`.
|
||||
- A small helper in `KeyboardManagerEditorUI` to load the script list (shell out to `Host.exe
|
||||
list --json`, like `Settings/SettingsManager.cs` reads its JSON).
|
||||
- **No KBM engine change** — it stays a `RunProgram` mapping.
|
||||
|
||||
> The editor-picker edits live in the shared KBM WinUI project, which needs the full PowerToys build
|
||||
> (VS + internal NuGet feeds) to compile — do them in that environment. The `kbm` command above is
|
||||
> the verifiable, build-free path that already delivers hotkey → PowerScript.
|
||||
|
||||
### 2. Explorer right-click (file actions)
|
||||
|
||||
A single compiled `IExplorerCommand` COM handler (pattern: `src/modules/NewPlus/NewShellExtensionContextMenu`)
|
||||
reads the registry, filters `kind:"file"` scripts whose `input.extensions` match the selection, and
|
||||
shows a dynamic submenu. Invoking an item runs `Host.exe run <id> --files <paths>`.
|
||||
|
||||
### Deferred (kept easy by the registry design)
|
||||
|
||||
Command Palette (one `ICommandProvider` extension enumerating system scripts) and Advanced Paste —
|
||||
both become additional registry-reading adapters. No core changes expected.
|
||||
|
||||
## Agent / AI tie-in (designed-for)
|
||||
|
||||
`Host.exe list --json` already yields a structured, permissioned capability list and `run <id>` is
|
||||
the invoke — so an MCP server can expose installed PowerScripts as user-consented tools. AI authoring
|
||||
("generate a PowerScript that…") emits a manifest + script folder the user reviews once.
|
||||
97
src/modules/PowerScripts/kbm-e2e.ps1
Normal file
97
src/modules/PowerScripts/kbm-e2e.ps1
Normal file
@@ -0,0 +1,97 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
End-to-end test helper for invoking a PowerScript from Keyboard Manager (new editor).
|
||||
|
||||
.DESCRIPTION
|
||||
Self-contained KBM e2e that doesn't require the full PowerToys runner:
|
||||
|
||||
1. Forces the *new* Keyboard Manager editor (useNewEditor = true).
|
||||
2. Launches PowerToys.KeyboardManagerEditorUI.exe so you can add a shortcut whose
|
||||
action is "PowerScript" -> pick a system script (e.g. "Volume Up") -> Save.
|
||||
3. Starts PowerToys.KeyboardManagerEngine.exe standalone, which reads the saved
|
||||
default.json and installs the keyboard hook. Press your shortcut and the engine
|
||||
runs PowerScripts.Host.exe run <id>.
|
||||
|
||||
Defaults assume a Debug build under <repo>\x64\Debug. Use -Configuration Release for a
|
||||
release layout.
|
||||
|
||||
.EXAMPLE
|
||||
# Configure a hotkey, then start the engine and test:
|
||||
pwsh -File kbm-e2e.ps1
|
||||
|
||||
.EXAMPLE
|
||||
# Skip the editor; just (re)start the engine to apply the current mappings:
|
||||
pwsh -File kbm-e2e.ps1 -EngineOnly
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[switch]$EngineOnly,
|
||||
[ValidateSet('Debug', 'Release')]
|
||||
[string]$Configuration = 'Debug'
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# Repo root = four levels up from src\modules\PowerScripts.
|
||||
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..\..')).Path
|
||||
$binRoot = Join-Path $repoRoot "x64\$Configuration"
|
||||
$editorExe = Join-Path $binRoot 'WinUI3Apps\PowerToys.KeyboardManagerEditorUI.exe'
|
||||
$engineExe = Join-Path $binRoot 'KeyboardManagerEngine\PowerToys.KeyboardManagerEngine.exe'
|
||||
$kbmDir = Join-Path $env:LOCALAPPDATA 'Microsoft\PowerToys\Keyboard Manager'
|
||||
$settings = Join-Path $kbmDir 'settings.json'
|
||||
|
||||
function Stop-ProcessesByName([string[]]$names)
|
||||
{
|
||||
$ids = Get-Process -ErrorAction SilentlyContinue | Where-Object { $names -contains $_.Name } | Select-Object -ExpandProperty Id
|
||||
foreach ($id in $ids) { try { Stop-Process -Id $id -Force } catch { } }
|
||||
}
|
||||
|
||||
if (-not (Test-Path $engineExe)) { throw "Engine not found: $engineExe. Build KeyboardManagerEngine first." }
|
||||
|
||||
# 1. Force the new editor.
|
||||
if (Test-Path $settings)
|
||||
{
|
||||
$json = Get-Content $settings -Raw | ConvertFrom-Json
|
||||
if ($json.properties.PSObject.Properties.Name -contains 'useNewEditor')
|
||||
{
|
||||
$json.properties.useNewEditor = $true
|
||||
}
|
||||
($json | ConvertTo-Json -Depth 10) | Set-Content $settings -Encoding UTF8
|
||||
Write-Host 'Set useNewEditor = true.'
|
||||
}
|
||||
|
||||
# 2. Launch the new editor (unless engine-only) and wait for the user to finish.
|
||||
if (-not $EngineOnly)
|
||||
{
|
||||
if (-not (Test-Path $editorExe)) { throw "Editor not found: $editorExe. Build KeyboardManagerEditorUI first." }
|
||||
|
||||
Write-Host ''
|
||||
Write-Host 'Opening the NEW Keyboard Manager editor.' -ForegroundColor Cyan
|
||||
Write-Host ' - Click "Add shortcut", set a trigger (e.g. Ctrl+Alt+U).'
|
||||
Write-Host ' - Action type -> PowerScript -> pick a System script (e.g. Volume Up).'
|
||||
Write-Host ' - Save, then CLOSE the editor window to continue.'
|
||||
Write-Host ''
|
||||
|
||||
# Pass this process id as the parent so the editor stays open until you close it.
|
||||
$editor = Start-Process -FilePath $editorExe -ArgumentList "$PID" -PassThru
|
||||
$editor.WaitForExit()
|
||||
Write-Host 'Editor closed.'
|
||||
}
|
||||
|
||||
# 3. (Re)start the engine standalone so it applies the saved mappings.
|
||||
Stop-ProcessesByName @('PowerToys.KeyboardManagerEngine')
|
||||
Start-Sleep -Milliseconds 500
|
||||
$engine = Start-Process -FilePath $engineExe -PassThru
|
||||
Start-Sleep -Seconds 1
|
||||
|
||||
if (Get-Process -Id $engine.Id -ErrorAction SilentlyContinue)
|
||||
{
|
||||
Write-Host ''
|
||||
Write-Host "KBM engine running (pid $($engine.Id))." -ForegroundColor Green
|
||||
Write-Host 'Press your configured shortcut now — the PowerScript should run.'
|
||||
Write-Host "Stop the engine when done: Stop-Process -Id $($engine.Id)"
|
||||
}
|
||||
else
|
||||
{
|
||||
throw 'Engine exited immediately. Check the KBM logs under the Keyboard Manager\Logs folder.'
|
||||
}
|
||||
11
src/modules/PowerScripts/nuget.config
Normal file
11
src/modules/PowerScripts/nuget.config
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
PROTOTYPE-ONLY: restore the isolated PowerScripts prototype projects from public nuget.org instead
|
||||
of the repo's auth-gated internal feed. Remove when promoting the module to the standard build.
|
||||
-->
|
||||
<configuration>
|
||||
<packageSources>
|
||||
<clear />
|
||||
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
|
||||
</packageSources>
|
||||
</configuration>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user