Compare commits

..

16 Commits

Author SHA1 Message Date
Muyuan Li (from Dev Box)
24ce23fa12 [PowerScripts] Decouple script id from folder + add trust-on-first-use gate
Two prototype improvements toward a shareable, safe script catalogue:

Catalogue-readiness (id decoupled from folder):
- Script `id` is now the portable identity; the id-must-equal-folder-name
  rule is removed so a shared/downloaded script keeps its id in any folder.
- Registry enforces id uniqueness across the catalogue (duplicate id is
  reported and skipped rather than silently shadowed).
- Manifest gains optional provenance fields: publisher, version, source.

Capability safety (trust-on-first-use):
- New ScriptIntegrity content hash (SHA-256 over entry body + kind +
  declared capabilities) and a persisted TrustStore (trust.json).
- Host `run` now gates every execution (the single choke point for context
  menu, KBM and agents): untrusted scripts prompt a native consent dialog
  showing name/publisher/source/capabilities/path; editing the body or
  escalating capabilities invalidates trust and re-prompts.
- `--no-consent` / POWERSCRIPTS_NO_CONSENT refuses instead of prompting
  (for non-interactive/agent callers); new `trust list|approve|revoke`
  subcommands; `list --json` exposes a `trusted` flag.
- Settings page shows a read-only Trust status row per script.

Tests: id decoupling, duplicate-id rejection, integrity stability/
invalidation, and trust-store round-trip (16/16 Core tests pass).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-01 16:53:48 +08:00
Muyuan Li (from Dev Box)
6d4a1dee6e [PowerScripts] Make the new KBM editor's PowerScript action e2e-ready
The new Keyboard Manager editor (KeyboardManagerEditorUI) already exposes a
PowerScript action, but on a Debug build it couldn't locate PowerScripts.Host.exe
(it isn't copied next to the editor), so the picker came up empty.

- PowerScriptsCatalog.ResolveHostPath: add the same dev-bin fallback the Settings
  view-model uses (walk up from the editor's base dir and probe
  src\modules\PowerScripts\PowerScripts.Host\bin\{Debug,Release}). The editor can
  now enumerate system scripts and resolve the host path for the saved RunProgram
  mapping in a dev build.
- Add kbm-e2e.ps1: a self-contained end-to-end helper that forces the new editor
  (useNewEditor=true), opens it to assign a hotkey to a system PowerScript, then
  runs KeyboardManagerEngine standalone so the hotkey actually fires
  Host.exe run <id> — no full runner required.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-30 11:38:48 +08:00
Muyuan Li (from Dev Box)
cd327dda07 [PowerScripts] Make Settings page a read-only script catalogue
The manifest.json is the single source of truth for a script's trigger
extensions, surfaces and capabilities. Drop the in-Settings extensions editor
(which only rewrote the manifest via the host) and instead show that
information read-only, so the UI reflects the manifest rather than duplicating
authoring of it.

- PowerScriptListItem: replace the editable ExtensionsText with read-only
  display projections (ExtensionsDisplay/SurfacesDisplay/CapabilitiesDisplay/
  RuntimeDisplay); surface Runtime/Surfaces/Capabilities from list --json.
- PowerScriptsPage.xaml: each script expander now lists Trigger file types
  (file scripts), Runtime, Surfaces and Capabilities as read-only rows.
- Remove SetScriptExtensions / ApplyExtensionsButton_Click. The host
  set-extensions command remains as a CLI/agent capability.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-30 11:08:27 +08:00
Muyuan Li (from Dev Box)
54bd07c08d [PowerScripts] Add Win11 modern context-menu handler (IExplorerCommand)
Legacy registry verbs only appear under "Show more options" on Windows 11.
This adds a self-contained IExplorerCommand COM server (sparse MSIX package)
that surfaces a top-level "PowerScript" entry with a dynamic submenu of the
file scripts matching the current selection.

- PowerScripts.Host: new `shell-menu --files` command emitting tab-separated
  id/name lines for matching file scripts (no JSON parser needed in native code).
- PowerScriptsContextMenu: WRL ClassicCom DLL (dllmain.cpp, dll.def) with a
  top-level command (GetState runs Host shell-menu, caches matches, hides when
  none), an IEnumExplorerCommand enumerator, and per-script items whose Invoke
  runs `Host run <id> --files <path>`. Host located next to the DLL.
- AppxManifest.xml registers the verb (ItemType Type="*", runtime visibility),
  build.cmd compiles via cl.exe, register.ps1 builds+publishes Host+deploys+
  registers the unsigned package (Add-AppxPackage -Register, Developer Mode).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-30 10:40:55 +08:00
Muyuan Li (from Dev Box)
35ccc7658d PowerScripts: let users edit a file script's trigger extensions in Settings
Add a per-script "Trigger on file types" editor (a SettingsExpander with
an editable extensions box + Apply) on the PowerScripts page for file
scripts. Applying calls a new host command, set-extensions <id> --ext
<.md .txt ...>, which rewrites the manifest's input.extensions via the
shared serializer, then re-registers the Explorer right-click submenu
(uninstall old verbs first so a changed extension leaves nothing stale).
list --json now surfaces input.extensions so the box shows current values.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-26 16:03:42 +08:00
Muyuan Li (from Dev Box)
b58b2f1a4c PowerScripts: locate the Host exe from in-repo dev builds
The Settings page lists scripts by shelling out to PowerScripts.Host.exe,
but a dev build never copies the Host next to Settings, so the list was
always empty even when the default scripts folder had scripts. Walk up
from the Settings base directory and probe the Host project's bin output
(Debug/Release) as a fallback, in addition to the existing next-to-exe
and %LOCALAPPDATA% locations.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-26 14:49:17 +08:00
Muyuan Li (from Dev Box)
0188c1ac69 PowerScripts: let users choose the scripts folder from Settings
Add a "Scripts folder" card to the PowerScripts page with Browse/Reset.
The chosen path is persisted to the shared config.json, and Core's
ResolveScriptsRoot now reads it (explicit > env > config > default) so
every surface (Settings list, Explorer context menu, KBM run) honors the
same folder. Selecting a folder reloads the list and re-registers the
context-menu entries.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-26 11:39:08 +08:00
Muyuan Li (from Dev Box)
11bda2709b PowerScripts: add ModuleTitle/Description strings to fix blank Dashboard
The Dashboard builds every module tile via resourceLoader.GetString of
the module's ModuleTitle key, which throws COMException "NamedResource
Not Found" for a missing key and aborts BuildModuleList, blanking the
Home page. Add the PowerScripts.ModuleTitle/ModuleDescription resources.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-26 11:38:57 +08:00
Muyuan Li (from Dev Box)
a618b2f2f9 PowerScripts: update README with implemented-surface table and e2e demo
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-25 16:30:43 +08:00
Muyuan Li (from Dev Box)
3cdbca3fa6 PowerScripts: add Settings module page listing scripts + enable toggle
- Add ModuleType.PowerScripts and Enabled.PowerScripts plumbing (EnabledModules,
  ModuleHelper, ModuleGpoHelper, App.GetPage)
- Add PowerScripts Settings nav item + page (NavigablePage) that lists installed
  scripts via 'PowerScripts.Host.exe list --json' and shows an enable toggle
- Enable toggle wires the Explorer context menu directly (Host shell-install/
  shell-uninstall), so the prototype is functional without a runner module DLL

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-25 16:28:58 +08:00
Muyuan Li (from Dev Box)
be711d12bf PowerScripts: add 'PowerScript' action to Keyboard Manager editor
Adds a new 'PowerScript' action type in the KBM editor's mapping control. The
picker lists system PowerScripts (via PowerScripts.Host.exe list --json) and saves
an ordinary RunProgram mapping invoking 'Host.exe run <id>', so a hotkey can launch
a PowerScript. Editor stays decoupled from PowerScripts assemblies by shelling out
to the Host CLI.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-25 16:15:53 +08:00
Muyuan Li (from Dev Box)
29ca6328f9 PowerScripts: add convert_md_to_txt + volume_up samples and context-menu registration
- Add two e2e sample scripts: convert_md_to_txt (file/.md) and volume_up (system)
- Add Host shell-install/shell-uninstall: registry-driven 'PowerScript' cascading
  submenu under SystemFileAssociations\\<ext>\\shell, one sub-verb per matching script
- Switch PowerScripts.Host TFM to net10.0-windows for registry access

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-25 15:50:41 +08:00
Muyuan Li (from Dev Box)
af2c3c61cd [PowerScripts] Add prototype module: core, host CLI, samples, tests
Introduces a prototype of the PowerScripts module (write a script once,
surface it across PowerToys). Includes:
- PowerScripts.Core: manifest schema, validation, registry, executor
- PowerScripts.Host: list/run/kbm CLI (shared invocation + KBM RunProgram mapping)
- PowerScripts.Core.Tests: MSTest unit tests (9 passing)
- Two sample scripts (system-snapshot, sha256-checksum) and README

Surfaces prioritized: Explorer right-click + Keyboard Manager. Build is
isolated from the repo (local Directory.Build.props/Packages/nuget.config)
while prototyping; remove to adopt standard PowerToys build rules.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-25 15:31:41 +08:00
Clint Rutkas
a864d421fc [Keyboard Manager] Fix stuck modifiers and dropped key-to-text remaps (#48571)
## Summary
Fixes stuck modifier keys and silently-dropped remaps on Keyboard
Manager's **single key → text** path, and adds unit coverage (including
a mockable injection-failure seam).

## What this changes
1. **Insert a dummy key event before releasing held modifiers.**
Releasing a lone Win or Alt key-up otherwise triggers the Start Menu /
menu bar. The dummy key absorbs it so the release is inert. The dummy +
releases are only injected when a modifier is actually held.
2. **Accept `WM_SYSKEYDOWN` as well as `WM_KEYDOWN`.** While Alt is held
the system delivers `WM_SYSKEYDOWN`, so the previous `WM_KEYDOWN`-only
guard silently dropped the remap whenever Alt was down.
3. **Route `Helpers::SendTextInput` through `InputInterface`** instead
of calling Win32 `SendInput` directly. Besides making the path mockable,
this stops the existing unit tests from injecting real keystrokes into
the OS during a test run. Text is still flushed per character to
preserve the existing batching workaround.
4. **Never re-press released modifiers.** Once a modifier key-up is
injected, `GetAsyncKeyState` reports it as up, so re-pressing risks
leaving it stuck if the user let go during injection. Leaving it
released is always safe.

## Testing
- New `MockedInput` failure seam (`SetSendVirtualInputShouldFail`).
- `RemappedKey_ShouldPassOriginalKeyThrough_WhenInjectionFails` —
verifies the original key is passed through when injection fails (the
core stuck-key behavior, previously untestable because the mock always
succeeded).
-
`HandleSingleKeyToTextRemapEvent_ShouldFireAndReleaseAlt_WhenAltIsHeld`
— covers fix #2 by asserting the remap still fires (and releases the
held Alt) when the key arrives as `WM_SYSKEYDOWN`.
- Full Keyboard Manager engine suite: **98/98 passing**, Release x64,
against current `main`.

This is one of a small set of related "stuck key" hardening fixes; each
stands alone.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-24 20:10:35 -07:00
Mike Griese
3331bdf02a CmdPal: add support for compact mode (#48801)
This is a bear of a PR. Watch out.
This PR adds support for compact mode to the command palette. In compact
mode, the results of the command palette window are collapsed by
default. And the only thing that is visible is the search bar. When the
user types, the window is expanded only just enough to show the
available results[^1]



https://github.com/user-attachments/assets/fd11bbc9-1173-426f-8f44-b513baf2ac5f



This is made possible by a fairly annoyingly substantial refactoring to
how our windowing is done. Animating the size or bounds of an HWND on
Windows is not fun. With pretty much any XAML application, you're going
to get at least one frame of blackness when you resize your window. The
trick then is to create an experience where it looks like your
application is resizing, but the HWND never actually resizes.

So the main bulk of this PR is actually just refactoring our window
handling. Our `MainWindow` class now becomes a dummy holder of _some
content_, and most of the main content is moved into the
`CmdPalMainControl` class. `MainWindow`'s job then is to handle a
transparent window that hosts some XAML content inside of it, and
pretend that content is the real bounds of the window. We need to fake
our NCHITTEST results, so that the edges of the XAML content act like
they're the edge of the actual HWND. We need to hide our actual window
frame and shadow, but then also re-create them in XAML around our
content.
Previously we've done work like this using a single full screen
transparent window with XAML content inside of it. However that has the
downside of not allowing the XAML content to be movable across different
monitors. By faking out the NCHITTEST results, we allow users to resize
and move the window using the normal user32 move size loop.
Our HWND is also cropped (with SetWindowRgn to the bounds of the shadow
around our XAML content. We need to include the shadow in the hit
testable region, because if we don't, then the shadow will be visibly
cropped on the edges.

In compact mode, instead of centering our window in the middle of the
monitor, the user can set a relative height where the search box opens
on that monitor. This defaults to about 60% up from the bottom of the
monitor, so that there's room for the results to expand downwards and
feel centered within the screen. This position is a setting, so users
can customize it to whatever they like.

I've also added a developer only debug build only internal setting,
which allows you to see the actual frame of our HWND. This makes it
easier to visually debug where the bounds of the window are and
understand a little bit more about the layout of our application. This
setting and functionality is disabled in release builds.

<img width="1334" height="1164" alt="cmdpal-compact-diagram"
src="https://github.com/user-attachments/assets/cb1c273d-37cc-4cb7-8680-e1878aa20c9c"
/>

Closes #38423


[^1]: with some caveats: pages with details expand fully always.
2026-06-24 22:35:37 +00:00
Michael Jolley
9ee0c7259b CmdPal: Dock Auto-hide (#48565)
This pull request introduces a new "Auto-hide" feature for the dock,
allowing users to collapse the dock until they hover over its screen
edge. The changes include updates to the settings model, UI,
localization resources, and automated tests to support and verify this
new functionality.

**Show me:**


https://github.com/user-attachments/assets/689625e8-9050-4a54-9c4b-9e303a3da63a

**Conflicted?**

"What if I have Taskbar and Dock on the same side and both with
auto-hide turned on?"

<img width="1437" height="264" alt="Screenshot 2026-06-14 144814"
src="https://github.com/user-attachments/assets/bd037a11-0653-4b9a-bd21-625aca03b901"
/>


Closes #46239

---------

Co-authored-by: root <root@io.bbq>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-24 16:39:58 -05:00
174 changed files with 6655 additions and 8930 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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 1215). 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).

View File

@@ -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.

View File

@@ -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>

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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>

View File

@@ -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)."
}

View File

@@ -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

View File

@@ -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)'

View File

@@ -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()

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -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

View File

@@ -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>

View File

@@ -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. -->

View File

@@ -1589,7 +1589,6 @@ SOFTWARE.
- OpenAI
- ReverseMarkdown
- ScipBe.Common.Office.OneNote
- ScreenRecorderLib
- SharpCompress
- Shmuelie.WinRTServer
- SkiaSharp.Views.WinUI

View File

@@ -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" />

View File

@@ -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\\**"
]
}
}

View File

@@ -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>
<!--

View File

@@ -38,6 +38,7 @@ namespace ManagedCommon
Workspaces,
GrabAndMove,
ZoomIt,
PowerScripts,
GeneralSettings,
}
}

View File

@@ -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 "&lt;text&gt;"</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}";
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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";
}
}

View File

@@ -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 &lt;slug&gt;</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 &lt;slug&gt; --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";
}
}

View File

@@ -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";
}
}

View File

@@ -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";
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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";
}
}

View File

@@ -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();
}

View File

@@ -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;
}
}
}
}

View File

@@ -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";
}
}

View File

@@ -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;
}
}

View File

@@ -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";
}
}

View File

@@ -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);
}
}
}

View File

@@ -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;
}

View File

@@ -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.

View File

@@ -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;
}

View File

@@ -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>&lt;root&gt;\&lt;plat&gt;\&lt;cfg&gt;</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;
}
}

View File

@@ -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;
}
}

View File

@@ -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());
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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 &lt;hwnd&gt;</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 &lt;name|pid&gt;</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 &lt;app&gt;</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);
}

View File

@@ -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;
}
}

View File

@@ -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\&lt;module&gt;\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));
}
}

View File

@@ -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>

View File

@@ -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&lt;T&gt;</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&lt;T&gt;</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&lt;T&gt;(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);
}
}

View File

@@ -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);
}
}

View File

@@ -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.
}
}
}

View File

@@ -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),
};
}

View File

@@ -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;
}
}

View File

@@ -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>

View File

@@ -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()

View File

@@ -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.
}
}
}

View File

@@ -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>

View File

@@ -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);
}
}
}

View File

@@ -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 530s 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);
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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>

View 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>

View File

@@ -0,0 +1,3 @@
<Project>
<!-- Empty: stops MSBuild from walking up to the repo-root targets for the prototype. -->
</Project>

View 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>

View File

@@ -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")));
}
}

View File

@@ -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>

View File

@@ -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);
}
}

View File

@@ -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));
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -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));
}

View File

@@ -0,0 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<RootNamespace>PowerScripts.Core</RootNamespace>
<AssemblyName>PowerScripts.Core</AssemblyName>
</PropertyGroup>
</Project>

View 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);
}
}

View File

@@ -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>&lt;id&gt;/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);
}
}

View File

@@ -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();
}
}

View File

@@ -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));
}
}

View 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 &lt;id&gt;</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;
}
}

View File

@@ -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>

View 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 &lt;id&gt;
/// - The Explorer context menu invokes: PowerScripts.Host.exe run &lt;id&gt; --files &lt;paths&gt;
/// - The KBM editor / agents enumerate via: PowerScripts.Host.exe list --json
///
/// Usage:
/// PowerScripts.Host list [--json] [--root &lt;dir&gt;]
/// PowerScripts.Host run &lt;id&gt; [--files &lt;f1&gt; &lt;f2&gt; ...] [--set name=value ...] [--root &lt;dir&gt;]
/// </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 &lt;id&gt; approve the script's current content without running it
/// trust revoke &lt;id&gt; 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>&lt;id&gt;\t&lt;name&gt;</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;
}
}

View 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\&lt;ext&gt;\shell\PowerScripts</c> whose nested
/// sub-verbs (one per matching script) invoke <c>PowerScripts.Host.exe run &lt;id&gt; --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);
}
}

View File

@@ -0,0 +1,9 @@
# Native handler build artifacts
*.dll
*.lib
*.exp
*.obj
*.pdb
*.ilk
# Host publish output used by register.ps1
hostpublish/

View File

@@ -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>

View 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

View File

@@ -0,0 +1,4 @@
EXPORTS
DllCanUnloadNow PRIVATE
DllGetClassObject PRIVATE
DllGetActivationFactory PRIVATE

View 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;
}

View File

@@ -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).'

View 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.

View 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.'
}

View 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