Compare commits

...

62 Commits

Author SHA1 Message Date
gkhmyznikov
fe0400d5ca lite recording profile 2026-07-01 00:08:30 -07:00
gkhmyznikov
fccf9aec14 fix recording framerate 2026-06-30 15:00:42 -07:00
gkhmyznikov
9defe2f7fa tight clipboard helper 2026-06-30 13:27:31 -07:00
gkhmyznikov
7d9dbc0087 add retries 2026-06-30 11:50:38 -07:00
gkhmyznikov
7da6ceacdd improve overlay detection 2026-06-29 23:24:26 -07:00
gkhmyznikov
75faf43360 try to fix the hangs 2026-06-29 20:55:32 -07:00
gkhmyznikov
8645c20c72 Screen recording 2.0 2026-06-29 19:05:34 -07:00
gkhmyznikov
2b5ac5f369 more logging 2026-06-29 17:51:53 -07:00
gkhmyznikov
ef97ba3ec5 more safety around winappcli calls 2026-06-29 16:07:53 -07:00
gkhmyznikov
e508705991 add more debug lines 2026-06-29 15:52:57 -07:00
gkhmyznikov
156c63e0e9 fix pattern match 2026-06-29 14:41:11 -07:00
gkhmyznikov
59872771ed Modernize mouse helper 2026-06-29 14:26:50 -07:00
gkhmyznikov
e53b0dacd9 disable screenshots, bump the fps 2026-06-29 14:08:48 -07:00
gkhmyznikov
1932466b8e add more time 2026-06-29 14:05:27 -07:00
gkhmyznikov
5ccdcdc6cc more log details 2026-06-29 13:25:14 -07:00
gkhmyznikov
45484f1c40 try to add slim build download 2026-06-29 13:16:11 -07:00
gkhmyznikov
2f2e8f1827 simplify screen ruler interactions 2026-06-29 11:44:09 -07:00
gkhmyznikov
a326168e54 [ScreenRuler.UITests.Next] Re-add a simplified overlay-gated tool selection
A leaner take on the reverted dce64e1056: SelectToolAndVerify does a single coordinate-free winappcli UIA invoke, one tracked cursor move onto the capture surface (cx-50, cy-50), then polls IsMeasureOverlayPresent inline to confirm the Measure Tool entered capture state before measuring. Dropped: the keyboard-accelerator fallback, the AcceleratorFor map, and the separate MoveOffToolbarAndWaitForOverlay helper (its two-step move collapsed to one). The Bounds drag's now-duplicate pre-move to (cx-50, cy-50) is removed since SelectToolAndVerify already parks the cursor there. Validated locally: full suite 5/5; the log shows the overlay-present gate then 'clipboard after drag = 100 x 100'.
2026-06-29 10:46:18 -07:00
gkhmyznikov
a0edb0ed1b Revert "[ScreenRuler.UITests.Next] Select the tool via winappcli invoke + keyboard backup, gated on the overlay"
Reverts the tool-selection change from dce64e1056: removes SelectToolAndVerify (winappcli UIA invoke + keyboard accelerator backup + overlay-window gate) and its helpers MoveOffToolbarAndWaitForOverlay, IsMeasureOverlayPresent and AcceleratorFor, and restores both Perform methods to the direct ruler.Find(button).Click(msPostAction: 500) tool selection. The shortcut-read (ee9f33918d) and the verbose execution-log artifact (3bbee56c89) are kept. Validated locally: full suite 5/5.
2026-06-29 10:20:54 -07:00
gkhmyznikov
3bbee56c89 [ScreenRuler.UITests.Next] Add the verbose per-test execution-log artifact
Backport of the execution-log artifact from 3eddfdd4 (the close-button rework from that commit is intentionally excluded - close stays as baseline's Close-button + WM_CLOSE fallback). New DiagnosticLogger writes timestamped [+N.NNs] steps to TestContext (inline in the run output) and saves them as a TestExecutionLog_*.log result artifact via AddResultFile. TestHelper gains an ambient logger (created in InitializeTest, flushed in CleanupTest) and Log(...) calls through activation, tool selection, the overlay check (logging the winappcli window list), and each measurement's clipboard value - so a failure shows exactly what happened. Validated locally: full suite 5/5; the inline log shows the overlay-present gate and 'clipboard after drag = 100 x 100'.
2026-06-27 14:48:46 -07:00
gkhmyznikov
dce64e1056 [ScreenRuler.UITests.Next] Select the tool via winappcli invoke + keyboard backup, gated on the overlay
Backport from 5788df21. Replace the blind ruler.Find(button).Click() tool selection with SelectToolAndVerify: a coordinate-free winappcli UIA invoke (so a 0x0 button rect doesn't matter), a keyboard-accelerator backup (Ctrl+1..4, shown in the button labels), and - crucially - a gate that confirms the full-screen measurement overlay window (PowerToys.MeasureToolOverlay / *OverlayWindow) appeared before measuring, so we never measure blind. Up to 3 invoke+keyboard attempts. Adds MoveOffToolbarAndWaitForOverlay, IsMeasureOverlayPresent, AcceleratorFor. Validated locally: full suite 5/5.
2026-06-27 14:42:33 -07:00
gkhmyznikov
ee9f33918d [ScreenRuler.UITests.Next] Read the activation shortcut from Settings (no hard-coded default)
Backport of the shortcut-read change from 35e5eb65 (the mouse-click change from that commit is intentionally excluded). ReadActivationShortcut polls the Settings ShortcutControl's EditButton HelpText until it reports a real chord (a non-modifier key present) and Assert.Fail's rather than silently falling back to a hard-coded Win+Ctrl+Shift+M - a wrong/stale default would send the wrong keys and mask the real problem. ParseShortcutText returns only the parsed keys; new ParseKeyToken handles modifiers + digits (Num0-9) + letters/F-keys/named keys; HasMainKey detects a real activatable chord.
2026-06-27 14:38:50 -07:00
gkhmyznikov
45e912ab24 make the single test config more explicit 2026-06-24 17:56:03 -07:00
gkhmyznikov
fb88583cdf supress OOBE 2026-06-24 17:42:58 -07:00
gkhmyznikov
004fd634f7 fix the settings window offscreen and cursor park 2026-06-24 15:50:25 -07:00
gkhmyznikov
7cf145b7f1 Merge remote-tracking branch 'origin/main' into pt-team/ui-tests-conversion 2026-06-24 13:50:43 -07:00
gkhmyznikov
10db8a3f27 add test helper template 2026-06-24 13:36:12 -07:00
gkhmyznikov
901383a9d9 port ScreenRuler tests 2026-06-24 13:34:30 -07:00
gkhmyznikov
f84c4bbf39 migration skill 2026-06-24 13:32:39 -07:00
gkhmyznikov
76b2046359 Merge remote-tracking branch 'origin/LegendaryBlair/module-verification-skill' into pt-team/ui-tests-conversion 2026-06-24 11:09:05 -07:00
gkhmyznikov
934047328f Merge branch 'gleb/ui-tests-2' into pt-team/ui-tests-conversion 2026-06-24 11:07:23 -07:00
gkhmyznikov
8900ae0835 fix the settings window sizing 2026-06-23 13:45:50 -07:00
gkhmyznikov
8dcd2d48cd try to fix old test workflow 2026-06-23 10:32:35 -07:00
gkhmyznikov
9c9566d1fd fix the nudge 2026-06-22 19:48:58 -07:00
gkhmyznikov
446009daba add shell execute for old framework 2026-06-22 16:31:52 -07:00
gkhmyznikov
96d8d70636 Try to fix sessions helper 2026-06-22 15:13:39 -07:00
gkhmyznikov
f13dd6eb61 address the PR review. 2026-06-22 14:53:25 -07:00
gkhmyznikov
5c487a5be3 try to fix the ColorPicker UI pickup 2026-06-22 11:12:36 -07:00
gkhmyznikov
0b0a698fed try to resolve the project startup 2026-06-19 13:48:02 -07:00
gkhmyznikov
858b9ea2db try to fix the settings launch 2026-06-19 10:20:17 -07:00
gkhmyznikov
7ead88631c fix env config 2026-06-18 16:38:42 -07:00
gkhmyznikov
40044ae268 fix the build location 2026-06-18 14:52:18 -07:00
gkhmyznikov
892fe244d7 update framework 2026-06-18 13:14:03 -07:00
Boliang Zhang (from Dev Box)
309ffe5515 Add powertoys-module-verification agent skill
Migrate the PowerToys module release-checklist verification skill into .github/skills/ following the agent-skills layout: SKILL.md + LICENSE.txt, scripts/ (12 PT helper .ps1), and references/ (UIA mechanics doc, per-module profiles, flow/format/setup docs). Folds the former entry-prompt into SKILL.md and demotes the winapp UI-testing doc to references/winapp-ui-testing.md (frontmatter stripped, MIT provenance retained).

Only checklists for modules already verified end-to-end (with a Module-Signoff report) are included for now: Environment Variables, File Locksmith, Image Resizer, New+, Peek, PowerRename. Remaining modules' checklists will be added as each is verified.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-18 16:02:04 +08:00
gkhmyznikov
0a93c4d179 fix the tests output 2026-06-17 20:22:08 -07:00
gkhmyznikov
eea70294ec fuzzing bump to 10 2026-06-17 17:11:18 -07:00
gkhmyznikov
6183b99020 disable onefuzz 2026-06-17 16:29:45 -07:00
gkhmyznikov
bfd089fc45 workarounds for winappcli install 2026-06-17 14:26:33 -07:00
gkhmyznikov
1208c019c1 Move the audit to before the full build 2026-06-17 13:33:33 -07:00
gkhmyznikov
45da84b377 Fix the audit step 2026-06-17 13:12:51 -07:00
gkhmyznikov
6ddfbdee48 add winappcli installation 2026-06-17 11:26:59 -07:00
gkhmyznikov
715e1bd0dd add plat for later 2026-06-11 13:42:50 -07:00
gkhmyznikov
5740651c63 add more stability 2026-06-11 13:35:59 -07:00
gkhmyznikov
e8df5a7c84 UITest.Next Phase 4: elevation, multi-monitor, status diagnostics
ElevationHelper (new): IsCurrentProcessElevated / IsProcessElevated via OpenProcessToken + TokenElevation. Session.IsElevated surfaces the target process's elevation (null when no PID).

MonitorInfo (new): GetAll / GetPrimary / Count via EnumDisplayMonitors + GetMonitorInfo, returning per-display bounds, work area, and primary flag for multi-monitor utility tests.

Session.Status(): winapp ui status --json for connection diagnostics.

Intentionally deferred (heavy / external-dep, and existing primitives already cover the common cases): perceptual-hash VisualAssert (GetPixelColor + Screenshot cover basic visual checks) and FFmpeg ScreenRecording (failure --capture-screen screenshots cover diagnostics).
2026-06-10 17:18:29 -07:00
gkhmyznikov
ba6612f375 UITest.Next Phase 3: rich interaction (drag, full keyboard/mouse, window helpers)
MouseHelper: add GetMousePosition, LeftDown/Up, RightDown/Up, MiddleDown/Up/Click, DoubleClick, ScrollWheel/Up/Down, and a stepped Drag(from,to). winappcli has no drag/wheel/raw-cursor verbs, so these stay Win32.

KeyboardHelper: extend Key enum with digits, F1-F12, arrows, Home/End/PageUp/PageDown/Insert; add PressKey/ReleaseKey/SendKey/SendKeySequence with extended-key handling for nav keys.

Element: add CLI-first Scroll(direction)/ScrollToEdge (winapp ui scroll) plus Win32 Drag/DragTo/KeyDownAndDrag using the element's search-reported center.

Elements: add Pane/Thumb/Custom/Tab wrappers (drag inherited from Element).

WindowHelper (new): WindowSize enum + SetWindowSize/SetMainWindowSize, GetWindowBounds/Center, GetDisplaySize/GetScreenCenter, GetPixelColor/GetPixelColorHex (GDI) — lets ColorPicker-style tests read on-screen pixels without a hidden XAML peer. IsWindowOpen stays CLI-based via WindowsFinder.

Session: add Attach(module, size) — window-scoped session with optional preset resize.
2026-06-10 17:15:27 -07:00
gkhmyznikov
d7f6f83b71 UITest.Next Phase 2: setup helpers, dev-build path resolution, diagnostics
ModulePaths: expand PowerToysModule enum to 10 modules; resolve exe via POWERTOYS_INSTALL_DIR override -> installed build -> repo dev-build output (x64/ARM64, Debug/Release). useInstallerForTest forces installed layout. Lets tests run against either an installed PowerToys or a local dev build.

EnvironmentConfig: IsInPipeline / UseInstallerForTest / Platform (ported from legacy harness).

SettingsConfigHelper: dependency-free (System.Text.Json.Nodes) ConfigureGlobalModuleSettings + UpdateModuleSettings, writing the per-user settings JSON directly (no Settings.UI.Library coupling).

Session diagnostics (CLI-first): Screenshot element-crop + --capture-screen + non-asserting TryScreenshot; Inspect --interactive/--hide-disabled/--hide-offscreen; InspectAncestors; GetFocused/GetFocusedName.

UITestBase: capture a --capture-screen PNG and attach it on test failure.

SessionHelper: RestartScope (kill -> wait exit -> relaunch + wait window).
2026-06-10 17:07:50 -07:00
gkhmyznikov
294bbcc029 UITest.Next Phase 1: CLI-first element wrappers + Element/Session extensions
Add ComboBox, CheckBox, RadioButton, Slider, TextBlock wrappers (all driven via winapp ui invoke/get-property/get-value/set-value).

Element: add DoubleClick (click --double), ScrollIntoView (scroll-into-view), live properties IsEnabled/IsOffscreen/Displayed/Selected/AutomationId + GetAttribute (get-property), WaitForValue with --contains. Make GetProperty tolerant of non-string/error output and expose EnsureBound to subclasses.

Session: add WaitForElement (wait-for appear).

Fix: TextBox set-value/get-value hardcoded -w <hwnd>, which targeted window 0 under process-scoped (-a) sessions; now uses the session's TargetFlag/TargetValue.
2026-06-10 17:00:52 -07:00
gkhmyznikov
63bd903d2d Rename settings test 2026-06-10 16:11:38 -07:00
gkhmyznikov
7ee6bc6500 Align the settings test with framework 2026-06-10 15:42:39 -07:00
gkhmyznikov
cf3d132a8f move n rename 2026-06-10 14:45:49 -07:00
gkhmyznikov
c37eaf00c3 remove smaller tests 2026-06-10 11:47:06 -07:00
gkhmyznikov
3bc472bcda initial commit 2026-06-10 11:18:17 -07:00
81 changed files with 8649 additions and 96 deletions

View File

@@ -2075,6 +2075,8 @@ wifi
wikimedia
wikipedia
winapi
winapp
winappcli
winappsdk
windir
WINDOWCREATED

View File

@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to the Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 Microsoft Corporation
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@@ -0,0 +1,192 @@
---
name: ui-tests-migration
description: "Migrate PowerToys module UI tests from the legacy WinAppDriver/Selenium harness (Microsoft.PowerToys.UITest) to the new winappcli-based harness (Microsoft.PowerToys.UITest.Next). Use when asked to port/convert/rewrite/modernize a module's UI tests to the .Next framework, create a new [Module].UITests.Next project alongside existing legacy tests, or stand up brand-new winappcli UI tests for a module that has none by reading its human test sign-off markdown. Covers the API mapping (By/Element/Session/UITestBase, KeyboardHelper/MouseHelper/ClipboardHelper), project/csproj scaffolding, naming rules, common PowerToys test recipes (toggle a module, read an activation shortcut, fire a global hotkey, inspect the clipboard, discover overlay/editor windows), and build/run validation. Keywords: UI test, UITests, UITestAutomation, UITestAutomation.Next, winappcli, winapp.exe, WinAppDriver, Selenium, Appium, migrate, port, convert, modernize, .Next, end-to-end, E2E, MSTest."
license: Complete terms in LICENSE.txt
---
# PowerToys UI-Tests Migration (legacy → `.Next`)
Convert a PowerToys module's UI tests from the legacy **WinAppDriver / Selenium / Appium** harness
(`Microsoft.PowerToys.UITest`, in `src/common/UITestAutomation/`) to the new **winappcli** harness
(`Microsoft.PowerToys.UITest.Next`, in `src/common/UITestAutomation.Next/`).
The new harness shells out to `winapp.exe` and parses its JSON — **no WinAppDriver server on :4723,
no Selenium/Appium NuGet packages, no `WindowsElement`/`WindowsDriver`.** The public *shape*
(`UITestBase`, `Session`, `Find<T>`, `By`, element wrappers like `ToggleSwitch`) is deliberately
similar, so most of the work is mechanical API mapping plus reworking a few patterns that don't
translate one-to-one (XPath selectors, stateful elements, instance mouse/keyboard helpers).
## When to use this skill
Use this skill when the task is to:
- **Port** a module's existing legacy UI tests to `.Next` (e.g. "migrate the ScreenRuler UI tests to
the new framework", "convert FancyZones.UITests to winappcli").
- **Create a new** `[Module].UITests.Next` project that re-implements the legacy tests with the new
harness, leaving the old project in place.
- **Stand up brand-new** `.Next` UI tests for a module that has **no** UI tests at all, by reading the
module's human test **sign-off markdown** (e.g. `ColorPickerUITest.md`) and turning each manual
checklist item into an automated test.
This skill is the *how*: the framework differences, the API mapping, the project scaffolding, the
naming rules, the recurring PowerToys test recipes, and the build/validate loop. The *what* (which
module, which tests) comes from the calling prompt.
> **Reference implementation — read these working examples before porting anything.** They are
> the ground truth for "what good looks like" with each harness:
> - **New (`.Next`)**: [ColorPickerEndToEndTests.cs](../../../src/modules/colorPicker/ColorPicker.UITests/ColorPickerEndToEndTests.cs)
> — full end-to-end scenario (navigate Settings → toggle module → read shortcut → fire hotkey →
> read overlay → click-capture → inspect editor), driven entirely through `winappcli`.
> - **Legacy**: [TestSpacing.cs](../../../src/modules/MeasureTool/Tests/ScreenRuler.UITests/TestSpacing.cs)
> + [TestHelper.cs](../../../src/modules/MeasureTool/Tests/ScreenRuler.UITests/TestHelper.cs)
> — a `UITestBase` subclass plus a static helper that navigates, toggles, reads the shortcut, fires
> the hotkey, and validates the clipboard.
> - **Worked Scenario-A port (validated 5/5, where the legacy suite scored 0/5 locally)**: the
> ScreenRuler suite ported from the legacy project above lives in
> [ScreenRuler.UITests.Next/TestHelper.cs](../../../src/modules/MeasureTool/Tests/ScreenRuler.UITests.Next/TestHelper.cs)
> + 5 test classes. It is the canonical port reference — cross-window toolbar discovery via
> `Session.FromProcess`, a DPI-aware `app.manifest`, cursor centering, and patient hotkey
> activation are all there because real runs needed them (see
> [references/patterns-and-pitfalls.md](references/patterns-and-pitfalls.md)).
## Required reads (in order)
1. **This `SKILL.md`** — the decision tree (which scenario), the naming rules, the high-level
workflow, and the build/validate loop.
2. **[references/framework-differences.md](references/framework-differences.md)** — the conceptual
deltas you MUST internalize before writing code: winappcli engine, stateless elements, selector
grammar (no XPath/CssSelector), session scopes (window vs process), lifecycle/hygiene/module
pre-enablement, multi-window discovery, and what the new harness does NOT (yet) provide.
3. **[references/api-mapping.md](references/api-mapping.md)** — the line-by-line cheat sheet:
namespaces, `By`, `Element` actions/properties, `Session`, `UITestBase`, the static
Keyboard/Mouse/Clipboard helpers, and the element-wrapper catalog. Keep this open while editing.
4. **[references/project-setup.md](references/project-setup.md)** — csproj scaffold, naming/placement
rules, `.slnx` registration, and how to build & run a `.Next` project. Uses the
[templates/](templates/) starter files.
5. **[references/porting-workflow.md](references/porting-workflow.md)** — the two end-to-end
playbooks: **A)** port existing legacy tests, and **B)** author tests from a human sign-off
markdown when none exist.
6. **[references/patterns-and-pitfalls.md](references/patterns-and-pitfalls.md)** — adaptable recipes
for the recurring PowerToys patterns (toggle a module + verify its process, read the activation
shortcut from a `ShortcutControl`, fire a global hotkey reliably, inspect the clipboard, discover
overlay/editor windows) and the gotchas that bite during migration.
## Pick your scenario
```mermaid
flowchart TD
A[Module to migrate] --> B{Does a legacy<br/>UITests project exist?}
B -- Yes --> C["Scenario A: PORT<br/>Create [Module].UITests.Next<br/>Re-implement each legacy test"]
B -- No --> D{Is there a human test<br/>sign-off .md?}
D -- Yes --> E["Scenario B: GREENFIELD<br/>Create [Module].UITests<br/>Turn each checklist item into a test"]
D -- No --> F[Ask the user for the<br/>test spec / sign-off doc]
```
| Scenario | Trigger | New project name | Source of test cases |
|---|---|---|---|
| **A — Port** | A legacy `[Module].UITests` (or similar) project already exists and references `UITestAutomation.csproj` | **`[Module].UITests.Next`** — keep the `.Next` suffix so it lives **alongside** the legacy project | The existing legacy test methods (1:1 re-implementation) |
| **B — Greenfield** | The module has **no** UI tests at all | **`[Module].UITests`** — **drop** the `.Next` suffix; there's nothing to live alongside | The module's human sign-off markdown (manual checklist), e.g. `ColorPickerUITest.md` |
Place the new project under **`src/modules/[Module]/Tests/[Module].UITests.Next/`** (or
`…/Tests/[Module].UITests/` for Scenario B). If the module already keeps tests in a different
`Tests/` layout, match the module's existing convention rather than forcing this one — see
[references/project-setup.md](references/project-setup.md).
> **Keep it abstract.** Every PowerToys module is unique and the legacy tests were written by
> different people in different styles. Treat the recipes in this skill as *adaptable patterns*, not
> a rigid script. Re-create the **intent and assertions** of each test; do not mechanically translate
> brittle, harness-specific scaffolding (Selenium `Actions`, XPath walks, manual driver attaches) when
> the new harness has a cleaner idiom.
## High-level workflow
Create a TODO list and work top-to-bottom. Each step links to the reference that drives it.
```markdown
- [ ] 1. Identify the module + scenario (A port / B greenfield) — this SKILL.md "Pick your scenario"
- [ ] 2. Read the two reference examples (ColorPicker .Next + ScreenRuler legacy) end-to-end
- [ ] 3. Inventory the source:
• Scenario A → list every [TestMethod] + shared helper in the legacy project
• Scenario B → read the module's sign-off .md; list each manual checklist item
— references/porting-workflow.md
- [ ] 4. Internalize the deltas — references/framework-differences.md
- [ ] 5. Scaffold the new project (csproj from template, name per the table, register in .slnx)
— references/project-setup.md
- [ ] 6. Re-implement tests, mapping each API as you go — references/api-mapping.md
+ recipes from references/patterns-and-pitfalls.md
- [ ] 7. Build the new project to exit code 0 — this SKILL.md "Build & validate"
- [ ] 8. (If a live desktop is available) run the tests; otherwise report that they build and are
ready to run, and summarize coverage vs. the source
```
## Build & validate
The `.Next` harness needs `winapp.exe` only at **run** time, not build time — the project has zero
managed dependency on the engine. So you can always compile-verify a migration even on an agent with
no winappcli installed.
```pwsh
# 0. FIRST build of a brand-new project: restore so the assets file exists, otherwise the build
# fails with NETSDK1004 "Assets file ... project.assets.json not found".
dotnet restore src\modules\<Module>\Tests\<Module>.UITests.Next\<Module>.UITests.Next.csproj -p:Platform=x64
# (Equivalently, run tools\build\build-essentials.cmd once at the start of the session.)
# 1. Build just the new test project (fast inner loop). Prefer the repo build script.
tools\build\build.cmd -Path src\modules\<Module>\Tests\<Module>.UITests.Next -Platform x64 -Configuration Debug
# Exit code 0 = success; non-zero = failure. On failure read the errors log next to the project:
# build.<Configuration>.<Platform>.errors.log
# 2. Run (needs a live desktop). A .Next project is a Microsoft.Testing.Platform Exe — run the
# produced exe directly with a TRX report; filter to one test/category for a tight loop.
$exe = "<repo>\x64\Debug\tests\<Module>.UITests.Next\net10.0-windows10.0.26100.0\<Module>.UITests.Next.exe"
& $exe --filter "TestCategory=<Cat>" --report-trx --report-trx-filename run.trx --results-directory <dir>
# --filter accepts "TestCategory=X" or "FullyQualifiedName~Y"; omit it to run everything.
# Exit 0 = all passed. Parse the .trx for per-test outcomes + failure messages.
```
- **Run it in a loop: write → build → run → diagnose → repeat.** UI tests surface environment-real
failures (DPI scaling, cursor position, hotkey-arming races) that only a live run reveals. Start
with one deterministic test (e.g. the activation/toggle test), get it green, then widen.
- **First, run the *legacy* suite once for a baseline — and run it ELEVATED.** The legacy harness
launches PowerToys via `ProcessStartInfo { Verb = "runas" }` (elevated), so a **non-elevated** test
host can't complete the launch and **every test fails at startup with a misleading `Win32Exception`
cascade** — a false 0/N that looks like "the tests are broken" but is purely the run method. (That's
why VS Test Explorer passes them: VS runs as admin.) Run from an **elevated** terminal: start
`WinAppDriver.exe` on `127.0.0.1:4723`, then run the built DLL with `vstest.console.exe` (see
[references/porting-workflow.md](references/porting-workflow.md) §A0 for the `-Verb RunAs` recipe).
A measurement failure on a scaled (non-100%) display is usually a pre-existing DPI issue (Pitfall
12), not something the port must reproduce — the ScreenRuler legacy suite scores **4/5** elevated
here (Bounds fails at 150% scale) while the `.Next` port scores **5/5**. `.Next` tests themselves
need **no** elevation (the new harness launches the runner non-elevated).
- **Always** build to exit code 0 before declaring the migration done. Fix every compile error — do
not leave `// TODO: port this` stubs that break the build.
- Running the tests requires a **live interactive desktop** plus `winapp.exe`
(`winget install Microsoft.winappcli`, or set `WINAPP_CLI_PATH`). The whole PowerToys runner is
launched by the harness (`PowerToys.exe --open-settings`) — you should see the Settings window
appear. If the environment has no desktop (headless agent), state that the project **builds clean
and is ready to run**, and list which source tests/checklist items each new `[TestMethod]` covers.
- New `.csproj` files under `src/` MUST `<Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" />`
right after `<Project Sdk=...>` (CI audits this). The template already does.
## What NOT to do
- **Do NOT delete or edit the legacy `[Module].UITests` project** in Scenario A. The `.Next` project
lives alongside it; removing the old one is a separate, explicit decision for the maintainers.
- **Do NOT touch product code.** This is a test-only migration. If a test needs a UIA hook that
doesn't exist (e.g. an `AutomationId` or a hidden automation-peer TextBlock), flag it for the user
rather than silently editing the module. (The ColorPicker example's `ColorHexAutomationPeer` hook
is a documented, pre-existing exception — see its class remarks.)
- **Do NOT port the legacy plumbing literally.** No Selenium `Actions`, no `WindowsDriver`/`WindowsElement`,
no `By.XPath`/`By.CssSelector`, no `:4723`. Map them to the winappcli idioms in
[references/api-mapping.md](references/api-mapping.md).
- **Do NOT add a `ProjectReference` to `UITestAutomation.csproj`** (the legacy harness) — reference
**`UITestAutomation.Next.csproj`** only.
- **Do NOT invent assertions** for a vague sign-off item. If a checklist line has no observable
pass/fail signal, implement what you can and leave a clearly-marked `TestContext.WriteLine` note
(or skip with an explanation) rather than asserting on something you can't actually read.
- **Do NOT introduce new third-party NuGet dependencies.** The `.Next` harness is intentionally
dependency-free (MSTest only). Use the Win32-based helpers it already ships.
## What is NICE to do
- **Improve the new UT Test framework if you see such opportunity**. The new framework works only with a few modules and may lack something other requires. If you see the old test uses something that we don't have in a new framework and it's handy, don't hesiate to port it to a new one. Or you may see the test uses a bunch of extra helpers ouside of test framework, which also may be a signal.

View File

@@ -0,0 +1,171 @@
# API mapping cheat sheet (legacy → `.Next`)
Keep this open while editing. Left column is the legacy `Microsoft.PowerToys.UITest` API; right column
is the `Microsoft.PowerToys.UITest.Next` equivalent. "—" means no direct member; see the Notes.
## Namespaces & usings
| Legacy | `.Next` |
|---|---|
| `using Microsoft.PowerToys.UITest;` | `using Microsoft.PowerToys.UITest.Next;` |
| `using OpenQA.Selenium;` / `…Appium…` | *(delete — no Selenium/Appium)* |
| `[TestClass] : UITestBase` | `[TestClass] : UITestBase` *(same shape; different namespace)* |
| `using Microsoft.VisualStudio.TestTools.UnitTesting;` | *(unchanged)* |
## `UITestBase` (the base class)
| Legacy | `.Next` | Notes |
|---|---|---|
| `: base(PowerToysModule.PowerToysSettings)` | `: base(PowerToysModule.PowerToysSettings)` | Same enum name; **values differ** — see enum table below. |
| `: base(scope, WindowSize.Large)` | `: base(scope, WindowSize.Large)` | Same `WindowSize` enum. |
| `: base(scope, size, commandLineArgs: new[]{…})` | `: base(scope, size, enableModules: new[]{…})` | 3rd arg changed from launch args to a deterministic module-enable list. |
| `Session` (property) | `Session` (property) | Same name. Legacy is `required set`; `.Next` is `private set` (assigned by `TestInit`). |
| `Find<T>(by, timeoutMS, global)` | `Find<T>(by, timeoutMS)` | No `global` param (see framework-differences §4). |
| `Find(name)` / `Find<T>(name)` | `Find(name)` / `Find<T>(name)` | Same. |
| `Has<T>/HasOne<T>(by, …, global)` | `Has<T>/HasOne<T>(by, …)` | No `global`. |
| `FindByPartialName<T>(s)` | `Find<T>(By.Name(s))` | winappcli `By.Name` is already a substring match. |
| `FindByPattern<T>(regex)` | `Session.FindAll<T>(By.Name(...))` + C# `Regex` | No base helper; filter in C#. |
| `FindByClassName<T>(c)` | `Find<T>(By.Name(...))` with a typed wrapper | Wrappers pin ClassName; or `FindAll` + filter on `.ClassName`. |
| `SendKeys(Key[])` / `SendKeySequence(Key[])` | `KeyboardHelper.SendKeys(Key[])` | Static helper (also `Session.SendKeys` passthrough). |
| `MoveMouseTo(x,y)` | `MouseHelper.MoveTo(x,y)` | Static helper. |
| `GetMousePosition()``(int,int)` | `MouseHelper.GetMousePosition()``(int X,int Y)` | Static helper. |
| `IsWindowOpen(name)` | `WindowsFinder.ListByApp(proc).Count > 0` | Or `SessionHelper.IsRunning(scope)` for a process check. |
| `RestartScopeExe(enableModules?)` | `RestartScope(enableModules?)` | Returns the fresh `Session`. |
| `ExitScopeExe()` | *(automatic)* `sessionHelper.StopIfStarted()` in `TestCleanup` | Rarely needed manually. |
## `PowerToysModule` enum (values differ!)
| Legacy value | `.Next` value | Notes |
|---|---|---|
| `PowerToysSettings` | `PowerToysSettings` | Same. The default; drive most modules through it. |
| `FancyZone` | `FancyZonesEditor` | **Renamed.** |
| `Hosts` | `Hosts` | Same. |
| `Runner` | `Runner` | Same. |
| `Workspaces` | `Workspaces` | Same. |
| `PowerRename` | `PowerRename` | Same. |
| `CommandPalette` | `CommandPalette` | Same. |
| `ScreenRuler` | `ScreenRuler` | Same. |
| `LightSwitch` | `LightSwitch` | Same. |
| *(n/a)* | `ColorPicker` | New entry (overlay module — drive via the Settings scope). |
## `By` selectors
| Legacy | `.Next` | Notes |
|---|---|---|
| `By.Name("x")` | `By.Name("x")` | winappcli = case-insensitive **substring** over Name/AutomationId. |
| `By.AccessibilityId("Id")` | `By.AccessibilityId("Id")` | **Preferred.** Also `By.Id("Id")`. |
| `By.Id("Id")` | `By.Id("Id")` / `By.AccessibilityId("Id")` | Same intent. |
| `By.ClassName("C")` | *(none)* | Use a typed wrapper, or `FindAll` + filter on `.ClassName`. |
| `By.XPath("//*[contains(@Name,'x')]")` | `By.Name("x")` | Substring search covers `contains(@Name)`. |
| `By.XPath("//*[@Name='x']")` | `By.Name("x")` (+ C# exact filter if needed) | |
| `By.XPath` (structural axes) | scoped `element.Find<T>(By.…)` or `FindAll` + C# filter | No XPath engine. |
| `By.CssSelector(...)` | *(none)* | Re-express as above. |
| *(n/a)* | `By.Slug("btn-x-1a2b")` | Direct slug from `inspect`/`search` output. |
## `Element` — properties
| Legacy | `.Next` | Notes |
|---|---|---|
| `Name` | `Name` | `.Next` is cached at Find time; re-find for fresh. |
| `ClassName` | `ClassName` | Cached. |
| `ControlType` | `ControlType` | Cached. |
| `Text` | `GetValue()` | TextPattern→ValuePattern→Selection→Name fallback. |
| `Enabled` | `IsEnabled` | Live read via `get-property`. |
| `Displayed` | `Displayed` (== `!IsOffscreen`) | Live read. |
| `Selected` | `Selected` | Live read (`IsSelected`). |
| `AutomationId` | `AutomationId` | Live read. |
| `HelpText` | `HelpText` | Live read (used for `ShortcutControl` text). |
| `Rect``Rectangle?` | `X`, `Y`, `Width`, `Height` (ints) | Cached snapshot; re-find if UI moved. |
| `GetAttribute("P")` | `GetAttribute("P")` / `GetProperty("P")` | Both live-read one UIA property. |
## `Element` — actions
| Legacy | `.Next` | Notes |
|---|---|---|
| `Click(rightClick=false, msPreAction=500, msPostAction=500)` | `Click(rightClick=false, msPostAction=200)` | **No `msPreAction`.** Uses UIA invoke (falls back to toggle/select/expand); `rightClick``click --right`. Add an explicit `Thread.Sleep` before if you relied on `msPreAction`. |
| `Click()` on a non-invokable element (TextBlock/ListItem) | `MouseClick(msPostAction=200)` | Real mouse simulation — use when the click is handled by an ancestor (the ColorPicker utility-stack label pattern). |
| `DoubleClick()` | `DoubleClick(msPostAction=200)` | Real mouse double-click. |
| Selenium `Actions` drag | `Drag(offsetX, offsetY, steps=10)` / `DragTo(target)` | Win32 mouse; uses cached center. |
| `Actions` key-down + drag | `KeyDownAndDrag(key, targetX, targetY, steps)` | Modifier-drag (FancyZones merge, tab tear-off). |
| `ReleaseKey(key)` | `KeyboardHelper.ReleaseKey(key)` | |
| `SetText`/`Clear`+`SendKeys` (TextBox) | `TextBox.SetText("v")` | `winapp ui set-value`. |
| `element.Find<T>(by)` | `element.Find<T>(by)` | Scoped search under the element. |
| `ScrollIntoView()` | `ScrollIntoView()` | Same. |
| — | `Scroll(ScrollDirection)`, `ScrollToEdge(toBottom)` | New scroll verbs. |
| — | `Focus()` | `winapp ui focus`. |
| — | `WaitForProperty(p, v, t)`, `WaitForValue(v, contains, t)`, `WaitForGone(t)` | Built-in waits (replace manual poll loops). |
## `Session`
| Legacy | `.Next` | Notes |
|---|---|---|
| `Find<T>(by, t, global)` / `Find(name)` | `Find<T>(by, t)` / `Find(name)` | No `global`. |
| `FindAll<T>(by, t, global)` | `FindAll<T>(by, t)` | No `global`; polls until found or timeout. |
| `Has`/`HasOne`/`Has<T>` | `Has`/`HasOne<T>`/`Has<T>` | Same intent. |
| `Attach(PowerToysModule)` / `Attach(windowName)` | `Session.Attach(module, size?)` / `Session.FromProcess(app)` / `WindowsFinder.WaitForWindowByApp(...)` | Re-bind to another window/process. |
| `SendKeys(Key[])` / `SendKey(key, …)` | `Session.SendKeys(Key[])` or `KeyboardHelper.SendKeys(Key[])` | Prefer the static helper. |
| `MoveMouseTo(x,y, …)` | `MouseHelper.MoveTo(x,y)` | Static. |
| `PerformMouseAction(MouseActionType.LeftClick)` | `MouseHelper.LeftClick()` | See action map below. |
| `SetMainWindowSize(size)` | `WindowHelper.SetWindowSize(hwnd, size)` | `hwnd = new IntPtr(Session.WindowHandle)`. |
| `MainWindowHandler` (`IntPtr`) | `WindowHandle` (`long`) / `WindowHandleArg` (string) | |
| — | `Inspect(depth, interactive, …)``JsonElement` | `winapp ui inspect --json` tree (the ColorPicker editor walk). |
| — | `WaitForElement(by, t)`, `WaitFor(Func<bool>, t)` | Built-in waits. |
| — | `Screenshot(path, element?, captureScreen?)` / `TryScreenshot(...)` | |
### `MouseActionType` → `MouseHelper`
| Legacy `PerformMouseAction(...)` | `.Next` |
|---|---|
| `MouseActionType.LeftClick` | `MouseHelper.LeftClick()` |
| `MouseActionType.RightClick` | `MouseHelper.RightClick()` |
| `MouseActionType.LeftDown` / `LeftUp` | `MouseHelper.LeftDown()` / `LeftUp()` |
| `MouseActionType.RightDown` / `RightUp` | `MouseHelper.RightDown()` / `RightUp()` |
| (scroll) | `MouseHelper.ScrollUp()` / `ScrollDown()` / `ScrollWheel(amount)` |
| (drag) | `MouseHelper.Drag(fromX, fromY, toX, toY, steps)` |
## Static helpers (new — no instance equivalent)
| Need | `.Next` helper |
|---|---|
| Send a key chord (incl. global Win-key hotkeys) | `KeyboardHelper.SendKeys(Key.LWin, Key.Shift, Key.C)` |
| Hold/release a key | `KeyboardHelper.PressKey(key)` / `KeyboardHelper.ReleaseKey(key)` |
| Move cursor / read cursor | `MouseHelper.MoveTo(x,y)` / `MouseHelper.GetMousePosition()` |
| Click at the current/again a point | `MouseHelper.LeftClick()` / `LeftClickAt(x,y)` / `RightClick()` / `DoubleClick()` |
| Read clipboard | `ClipboardHelper.GetText()` |
| Clear clipboard | `ClipboardHelper.Clear()` |
| Set clipboard | `ClipboardHelper.SetText("v")` |
| Wait for clipboard to change | `ClipboardHelper.WaitForText(ignoredValue, timeoutMS)` |
| Seed module on/off baseline | `SettingsConfigHelper.ConfigureGlobalModuleSettings("ColorPicker", …)` |
| Edit a module's own settings.json | `SettingsConfigHelper.UpdateModuleSettings(name, default, json => {…})` |
> The legacy `TestHelper.ClearClipboard`/`GetClipboardText` STA-thread wrappers are replaced by
> `ClipboardHelper` (which already runs on an STA thread internally). Delete the hand-rolled STA code.
## Element wrappers (`Find<T>`)
| Wrapper | Legacy | `.Next` | Notes |
|---|---|---|---|
| `Element` | ✅ | ✅ | Base. |
| `Button` | ✅ | ✅ | |
| `CheckBox` | ✅ | ✅ | |
| `ComboBox` | ✅ | ✅ | `.Select(item)` / `.SelectByText(text)` / `.SelectedText`. |
| `RadioButton` | ✅ | ✅ | |
| `Slider` | ✅ | ✅ | |
| `Tab` | ✅ | ✅ | |
| `TextBlock` | ✅ | ✅ | |
| `TextBox` | ✅ | ✅ | `.SetText(v)` / `.Value`. |
| `ToggleSwitch` | ✅ | ✅ | `.IsOn` / `.Toggle(bool)`. Pins `ClassName="ToggleSwitch"`. |
| `Thumb` | ✅ | ✅ | |
| `NavigationViewItem` | ✅ | ✅ | UIA `ListItem`. |
| `Pane` | ✅ | ✅ | |
| `Custom` | ✅ | ✅ | UIA `Custom` (FancyZones zones, Workspaces canvas). |
| `Window` | ✅ | ✅ | |
| `Group` | ✅ | ❌ | Use `Find<Element>` or add a wrapper. |
| `HyperlinkButton` | ✅ | ❌ | Use `Find<Button>` (it's a Button under UIA) or add a wrapper. |
## `Key` enum
Both frameworks expose a `Key` enum. The `.Next` `Key` (in `KeyboardHelper.cs`) uses `LWin` (not
`Win`). When porting a shortcut parser, map `"win"`/`"windows"``Key.LWin`. Letters `A``Z`,
digits `Num0``Num9`, `F1``F12`, and the usual `Ctrl/Shift/Alt/Esc/Enter/Tab/Space/Arrows` are all
present.

View File

@@ -0,0 +1,167 @@
# Framework differences: legacy vs `.Next`
The conceptual deltas you must internalize before porting. Read this once, end-to-end, then keep
[api-mapping.md](api-mapping.md) open for the mechanical lookups.
## At a glance
| Aspect | Legacy `Microsoft.PowerToys.UITest` | New `Microsoft.PowerToys.UITest.Next` |
|---|---|---|
| Folder | `src/common/UITestAutomation/` | `src/common/UITestAutomation.Next/` |
| Namespace | `Microsoft.PowerToys.UITest` | `Microsoft.PowerToys.UITest.Next` |
| Assembly | `Microsoft.PowerToys.UITest` | `Microsoft.PowerToys.UITest.Next` |
| Engine | WinAppDriver server on `http://127.0.0.1:4723` + Selenium/Appium | `winapp.exe` CLI (shell out, parse `--json`) |
| Driver object | `WindowsDriver<WindowsElement>`, `WindowsElement` | none — every call is a `winapp ui …` subprocess |
| 3rd-party deps | `Appium.WebDriver`, `Selenium.WebDriver`, … | none (MSTest only) |
| Element model | **stateful** — wraps a live `WindowsElement` | **stateless** — wraps a selector; every read/action re-shells out |
| Selector grammar | Selenium `By` (Name, ClassName, Id, **XPath**, **CssSelector**, AccessibilityId) | winappcli `By` (**Name=text**, **AccessibilityId**, **Slug**) — no XPath/CSS |
| Find scope flag | `bool global` parameter on every `Find` | no `global` param — session scope (`-w`/`-a`) decides reach |
| Mouse/keyboard | instance methods on `Session`/`UITestBase` (`MoveMouseTo`, `PerformMouseAction`, `SendKeys`) | **static** helpers (`MouseHelper`, `KeyboardHelper`, `ClipboardHelper`) |
| Run-time prereq | WinAppDriver installed + running | `winapp.exe` on PATH (or `WINAPP_CLI_PATH`) |
| Elevation | **Required** — harness launches the runner via `Verb="runas"`; a non-elevated host fails at launch | **Not required** — harness launches the runner non-elevated (works from a plain terminal) |
| Test runner | MSTest (VSTest) | MSTest via Microsoft.Testing.Platform (`EnableMSTestRunner`) |
## 1. The engine: winappcli, not WinAppDriver
The legacy harness spins up a WinAppDriver server and talks Selenium WebDriver to it. The `.Next`
harness has **no server and no session protocol**`WinappCli.Invoke(...)` starts `winapp.exe`,
captures stdout/stderr/exit-code, and (for `--json` verbs) parses the envelope. Every `Find`,
property read, click, and key press is an independent process invocation.
Consequences you'll feel while porting:
- There is no long-lived "driver" to attach/dispose. `Session` is a lightweight value object holding a
target flag (`-w <hwnd>` or `-a <app>`) and metadata. `Session.Cleanup()` is a no-op.
- "Is the CLI installed?" is checked once per run (`WinappCli.IsAvailable()` from `UITestBase`), and a
missing CLI fails fast with an install hint — you don't manage that.
- Errors surface as non-zero exit codes + stderr, wrapped into MSTest `Assert` failures with a
`winapp … -> exit N; stderr: …` description. There are no `WebDriverException`/`NoSuchElementException`
types to catch — use the `Has*`/`WaitFor*` probes instead of try/catch on Find.
## 2. Elements are stateless
Legacy `Element` wraps a live `WindowsElement`; properties like `Enabled`, `Text`, `Rect` read the
cached Selenium object. `.Next` `Element` wraps **only a selector** (a winappcli slug or text query)
plus the owning `Session`. The `ControlType`, `ClassName`, `Name`, `X/Y/Width/Height` fields are the
values captured **at `Find` time**; every *fresh* read (`IsEnabled`, `GetProperty(...)`, `GetValue()`)
shells out again via `winapp ui get-property`/`get-value`.
Porting implications:
- Cached geometry (`X`, `Y`, `Width`, `Height`) is a **snapshot**. If the UI moved since `Find`,
re-find before using coordinates for a `Drag`/`MouseClick`.
- There is no `element.Rect` returning a live `Rectangle`. Use the cached `X/Y/Width/Height` ints, or
re-find.
- Don't hold an `Element` across a navigation/relaunch and expect it to still resolve — re-find after
the tree changes.
## 3. Selectors: `By.Name` / `By.AccessibilityId` / `By.Slug` only
The new `By` (in `By.cs`) is **not** Selenium's `By`. It has three kinds:
| `.Next` factory | Meaning | winappcli mechanic |
|---|---|---|
| `By.Name(text)` | case-insensitive substring search over Name/AutomationId | `winapp ui search "<text>"` |
| `By.AccessibilityId(id)` / `By.Id(id)` | stable `AutomationId` | search by id |
| `By.Slug(slug)` | a semantic slug printed by `inspect`/`search` (e.g. `btn-close-d1a0`) | direct slug selector |
There is **no** `By.XPath`, `By.ClassName`, or `By.CssSelector`. To port those:
- `By.ClassName("ToggleSwitch")` → use the typed wrapper (`Find<ToggleSwitch>(By.Name(...))`), which
pins `ClassName` via `TargetClassName`. The wrapper's class filter replaces the ClassName selector.
- `By.XPath("//*[contains(@Name,'foo')]")` (the legacy `FindByPartialName`) → `By.Name("foo")` already
does substring matching in winappcli, so a partial-name XPath usually collapses to a plain
`By.Name`.
- `By.XPath("//*[@Name='exact']")``By.Name("exact")` (winappcli substring-matches; if you need to
disambiguate, `FindAll` then filter in C# on `m.Name == "exact"`).
- Complex structural XPath (parent/child axes) → there is no direct equivalent. Re-express as: find the
container by id, then `container.Find<T>(By.…)` (scoped search), or `Session.FindAll<T>` + a C#
`Where(...)` on the cached `ControlType`/`ClassName`/`Name`/coordinates. The ColorPicker example
does exactly this (`FindAll<Element>(By.Name("Color Picker"))` then `.OrderByDescending(m => m.X)`).
**Prefer `By.AccessibilityId`.** When porting, if a legacy test used a fragile `By.Name` or XPath, check
the module's XAML for an `x:Name`/`AutomationProperties.AutomationId` and switch to `By.AccessibilityId`
— it's the most stable selector and what the new examples favor.
## 4. No `global` parameter — session scope decides reach
Legacy `Find<T>(by, timeoutMS, global)` had a `global` bool to widen the search beyond the current
window. `.Next` `Find<T>(by, timeoutMS)` has **no** `global` param. Instead, the **session scope**
governs reach:
- **Window scope** (`-w <hwnd>`, the default from `UITestBase`/`SessionHelper.Init`): searches within
one window. Use when a process owns several windows and you must pin one (Settings vs. its
`PopupHost`; ColorPicker overlay vs. editor).
- **Process scope** (`-a <name|pid>`, via `Session.FromProcess(...)`): searches all of a process's
windows; every call re-resolves, so it transparently survives window replacement (re-navigation,
page swaps, dropdown popups in a separate `PopupHost`). Closest analog to the legacy `global: true`.
To reach a **different** window (e.g. an editor/overlay the module just spawned), don't pass a flag —
discover it with `WindowsFinder`/`WindowControl` (see §6) and get a new `Session` bound to it.
## 5. Lifecycle, hygiene, and module pre-enablement (`UITestBase`)
Both bases run `[TestInitialize]`/`[TestCleanup]`, but the `.Next` base centralizes things the legacy
tests often did by hand:
- **Constructor:** `UITestBase(PowerToysModule scope = PowerToysSettings, WindowSize size = UnSpecified, string[]? enableModules = null)`.
- `scope` — which module/window to drive. **Most module tests use `PowerToysModule.PowerToysSettings`**
and drive the utility *through* the Settings UI + its activation hotkey, because the **runner**
(`PowerToys.exe`) owns module toggles and the centralized keyboard hook. Launching a module's UI
exe standalone bypasses that and the hotkey never fires.
- `size` — applied after the window appears; `UnSpecified` maximizes (deterministic on CI). Maps to
the legacy `WindowSize` ctor arg.
- `enableModules` — when non-null, exactly these modules are enabled (others disabled) in the global
`settings.json` **before** launch. This is the deterministic replacement for the legacy
`commandLineArgs`/`StartExe(enableModules)` pattern. The names are the keys under `"enabled"` (e.g.
`"ColorPicker"`, `"FancyZones"`, `"Measure Tool"`).
- **Pre-test hygiene** runs automatically: `Win+M` (minimize all) → `Esc` → kill stale PowerToys
processes (`StaleProcessNames`, overridable). You usually delete the legacy test's manual
`CloseOtherApplications`/`Win+M` calls.
- **Teardown** stops only what the base launched (`StopIfStarted()`), so you rarely need a manual
process-kill in `[TestCleanup]`. (Per-test cleanup of *spawned* windows — an overlay/editor the test
popped — is still the test's job; use `WindowControl.TryCloseByApp` in a `finally`.)
- **`RestartScope(enableModules?)`** replaces the legacy `RestartScopeExe` — re-seeds modules,
kills + relaunches, reapplies size, returns the fresh `Session`.
- **Class-shared window:** override `protected bool ReuseScopeAcrossTests => true;` to launch once per
class and reuse the window across `[TestMethod]`s (skips per-test hygiene/relaunch). Use for smoke
suites with many cheap cases against one window. Default is per-test isolation.
## 6. Multi-window discovery
The legacy harness used `Session.Attach(module|windowName)` to switch the driver to another window.
`.Next` discovers windows with two static helpers:
- **`WindowsFinder`** (read/wait): `ListByApp(appNameOrPid)`, `ListAll()`,
`WaitForWindowByApp(app, predicate, timeoutMS)`, `WaitForWindowByTitle(...)`,
`WaitForWindowByProcess(...)`. Returns `WindowInfo` (hwnd/title/process/size/className) and, for the
`WaitFor*` variants, a ready-to-use `Session` bound to that window. This is how the ColorPicker test
finds the overlay (`Width<300 && Height<200`) vs. the editor (`Width>300 && Height>300`) from the
same `PowerToys.ColorPickerUI` process.
- **`WindowControl`** (tolerant cleanup): `TryCloseByApp(app[, predicate])`, `TryFocusByApp`,
`TryKillProcessByName` (exact), `TryKillProcess` (substring), `SafeCloseAndFocus`. Every method
swallows exceptions and returns a bool — designed for `finally` blocks so cleanup never masks the
real failure.
Note: unfiltered `WindowsFinder.ListAll()` drops windows with no Win32 title (e.g. the ColorPicker
editor exposes its name only via UIA). **Use `ListByApp`/`WaitForWindowByApp` with a process filter**
for those.
## 7. What `.Next` does NOT (yet) provide
When a legacy test relies on one of these, adapt rather than expecting a drop-in:
- **`By.XPath` / `By.CssSelector` / `By.ClassName`** — none exist (see §3).
- **`FindByPattern` / regex Name matching** as a base helper — re-express with `FindAll<T>(By.Name(...))`
+ a C# `Regex`/`Where` on the cached `Name` (the legacy base's `FindByNamePattern` shows the shape).
- **`Group`, `HyperlinkButton` wrappers** — the legacy `Element/` set has them; `.Next` doesn't.
Use `Find<Element>` (or `Find<Button>` for a hyperlink button, which is a Button under UIA), or add a
tiny wrapper subclass mirroring `Button.cs`/`NavigationViewItem.cs` if you need the type.
- **`element.Text` / `element.Rect` / `element.Enabled`** (legacy names) — use `GetValue()` /
`X,Y,Width,Height` / `IsEnabled` (see [api-mapping.md](api-mapping.md)).
- **Instance `Session.SendKeys`/`MoveMouseTo`/`PerformMouseAction`** — exist as a thin `Session.SendKeys`
passthrough, but prefer the static `KeyboardHelper`/`MouseHelper`.
If a genuinely missing capability blocks a port, add it to the `.Next` harness in a small, focused way
that mirrors the existing file style (one wrapper class, or one static helper method) — and call it out
to the user. Don't pull in a NuGet package.

View File

@@ -0,0 +1,357 @@
# Patterns & pitfalls
Adaptable recipes for the recurring PowerToys UI-test patterns, plus the gotchas that bite during a
`.Next` migration. **These are patterns, not a script** — every module differs; lift the shape, not
the literal strings. All snippets assume `using Microsoft.PowerToys.UITest.Next;` and a class deriving
from `UITestBase`.
## Recipe 1 — Navigate to a module's Settings page
Two common shapes. Prefer the NavigationView item by `AutomationId` when the module has one:
```csharp
// Stable: the left-nav item (a ListItem) by AutomationId. Expand the parent group first if needed.
if (Session.Has(By.AccessibilityId("ScreenRulerNavItem"), 500) == false)
{
Session.Find<NavigationViewItem>(By.AccessibilityId("SystemToolsNavItem")).Click(msPostAction: 500);
}
Session.Find<NavigationViewItem>(By.AccessibilityId("ScreenRulerNavItem")).Click(msPostAction: 500);
```
```csharp
// Dashboard utility-stack label that has no InvokePattern (the click is handled by the ancestor
// SettingsCard). A Name search may return several elements — disambiguate, then MouseClick (real
// mouse), not Click (UIA invoke), because the label itself isn't invokable.
var matches = Session.FindAll<Element>(By.Name("Color Picker"));
var label = matches.Where(m => m.ClassName.Equals("TextBlock", StringComparison.OrdinalIgnoreCase))
.OrderByDescending(m => m.X) // rightmost = the utility-stack label
.First();
label.MouseClick(msPostAction: 800);
```
> Pitfall: a `By.Name("Color Picker")` substring search can match a quick-access tile, its label, the
> utility-stack label, and a `ToggleSwitch`. Use `FindAll` + a C# filter on `ClassName`/`ControlType`/
> coordinates instead of assuming a single hit.
## Recipe 2 — Toggle a module on/off and verify its process
```csharp
// The page-level enable switch. ToggleSwitch pins ClassName="ToggleSwitch", so the Name search
// won't grab a sibling Button with the same Name (e.g. a dashboard card).
var toggle = Find<ToggleSwitch>(By.Name("Color Picker"));
bool initial = toggle.IsOn;
toggle.Toggle(false); // flips only if currently on
Assert.IsTrue(toggle.WaitForProperty("ToggleState", "Off", 5_000), "UI didn't flip to Off.");
Assert.IsTrue(WaitForProcess("PowerToys.ColorPickerUI", false, 10_000), "Process didn't exit.");
toggle.Toggle(true);
Assert.IsTrue(toggle.WaitForProperty("ToggleState", "On", 5_000), "UI didn't flip to On.");
Assert.IsTrue(WaitForProcess("PowerToys.ColorPickerUI", true, 10_000), "Process didn't start.");
// ... restore `initial` in a finally ...
```
```csharp
// Poll for process presence — no built-in, so keep a small helper (from the ColorPicker example).
private static bool WaitForProcess(string name, bool expected, int timeoutMS)
{
var deadline = DateTime.UtcNow + TimeSpan.FromMilliseconds(timeoutMS);
while (DateTime.UtcNow < deadline)
{
if ((Process.GetProcessesByName(name).Length > 0) == expected) return true;
Thread.Sleep(250);
}
return false;
}
```
> Process names are the `-a` names (no `.exe`): `PowerToys.ColorPickerUI`, `PowerToys.ScreenRuler`
> (actually `PowerToys.MeasureToolUI`), `PowerToys.FancyZonesEditor`, etc. — see `ModuleConfigData.cs`
> in the harness for the authoritative list.
## Recipe 3 — Read the activation shortcut from a `ShortcutControl`
PowerToys' `ShortcutControl` renders the current chord on its inner `EditButton`, exposing the readable
text (e.g. `"Win + Shift + C"`) via `AutomationProperties.HelpText`. `x:Name` reflects as the
`AutomationId` in WinUI when none is set, so:
```csharp
var editButton = Find<Button>(By.AccessibilityId("EditButton"));
string shortcutText = editButton.HelpText; // "Win + Shift + C"
Key[] keys = ParseShortcutText(shortcutText); // -> [LWin, Shift, C]
```
When the page has several shortcut controls, scope the search under the specific card first:
```csharp
var card = Session.Find<Element>(By.AccessibilityId("Shortcut_ScreenRuler"));
var editButton = card.Find<Element>(By.AccessibilityId("EditButton"));
```
```csharp
// Shortcut-string parser (ports verbatim from either example; note "win" -> Key.LWin).
private static Key[] ParseShortcutText(string s)
{
var parts = s.Split(new[] { " + ", "+", " " }, StringSplitOptions.RemoveEmptyEntries);
var keys = new List<Key>();
foreach (var raw in parts)
{
var p = raw.Trim().ToLowerInvariant();
Key? k = p switch
{
"win" or "windows" => Key.LWin,
"ctrl" or "control" => Key.Ctrl,
"shift" => Key.Shift,
"alt" => Key.Alt,
_ when p.Length == 1 && p[0] >= 'a' && p[0] <= 'z' => (Key)Enum.Parse(typeof(Key), p.ToUpperInvariant()),
_ => null,
};
if (k.HasValue) keys.Add(k.Value);
}
return keys.ToArray();
}
```
## Recipe 4 — Fire a global hotkey reliably
The runner arms its low-level keyboard hook **asynchronously** after a module is enabled, so the very
first chord can be lost. Re-send with patient polling between attempts — and don't re-send too eagerly,
because for some modules re-sending hides/re-shows the target window:
```csharp
const int attempts = 3;
Session? overlay = null;
for (int i = 1; i <= attempts && overlay is null; i++)
{
KeyboardHelper.SendKeys(keys);
overlay = WindowsFinder.WaitForWindowByApp(
"PowerToys.ColorPickerUI", w => w.Width < 300 && w.Height < 200, timeoutMS: 2_500);
if (overlay is null)
{
MouseHelper.MoveTo(cx + 60, cy + 60); // recovery nudge for cursor-following overlays
overlay = WindowsFinder.WaitForWindowByApp(
"PowerToys.ColorPickerUI", w => w.Width < 300 && w.Height < 200, timeoutMS: 2_500);
}
}
Assert.IsNotNull(overlay, "Activation window did not appear after retries.");
```
> Only the runner's centralized hook can catch a global PowerToys hotkey, which is *why* tests launch
> through the Settings/runner scope. `KeyboardHelper.SendKeys` holds `LWin` via `keybd_event` while
> sending the rest through SendInput — pure injection doesn't reliably trigger `RegisterHotKey`.
## Recipe 5 — Inspect the clipboard around an action
```csharp
ClipboardHelper.Clear();
MouseHelper.LeftClick(); // the action that copies
string captured = ClipboardHelper.WaitForText(ignoredValue: string.Empty, timeoutMS: 3_000);
Assert.IsFalse(string.IsNullOrEmpty(captured), "Nothing was copied within 3s.");
```
`ClipboardHelper` already marshals to an STA thread and swallows contention errors — delete any legacy
hand-rolled STA wrapper.
## Recipe 6 — Discover overlay vs. editor windows from one process
```csharp
// Small overlay (transparent/topmost) — filter by size.
var overlay = WindowsFinder.WaitForWindowByApp(
"PowerToys.ColorPickerUI", w => w.Width < 300 && w.Height < 200, timeoutMS: 2_500);
// Larger editor window from the SAME process.
var editor = WindowsFinder.WaitForWindowByApp(
"PowerToys.ColorPickerUI", w => w.Width > 300 && w.Height > 300, timeoutMS: 10_000);
// Each returns a Session bound to that window; search within it:
var peer = overlay!.Find(By.AccessibilityId("ColorHexAutomationPeer"), timeoutMS: 2_000);
string hex = peer.Name;
```
> Use `ListByApp`/`WaitForWindowByApp` (process-filtered), **not** `ListAll`, for windows that expose
> their name only via UIA (no Win32 title) — the unfiltered list drops them.
## Recipe 7 — Walk a window's UIA tree (when there's no single selector)
```csharp
var tree = editor.Inspect(depth: 12); // JsonElement: { windows:[{ elements:[{type,name,value,children}] }] }
var values = new List<(string Type, string Name, string Value)>();
WalkElements(tree, values); // recursive walk (see ColorPicker example)
bool found = values.Any(v =>
v.Name.Contains(captured, StringComparison.OrdinalIgnoreCase) ||
v.Value.Contains(captured, StringComparison.OrdinalIgnoreCase));
Assert.IsTrue(found, $"'{captured}' not found in editor tree.");
```
Use this when a value can appear in any of several controls (e.g. ColorPicker's editor renders the
captured color in whichever format control matches) and you only need "it's somewhere in the tree".
## Recipe 8 — Read a value the UIA Name hides
When `AutomationProperties.Name` overrides the UIA Name with a friendly label (e.g. a color *name*
instead of its HEX), `GetValue()` still reads the underlying Text/Value binding:
```csharp
string displayed = Find<TextBlock>(By.AccessibilityId("SomeLabel")).GetValue(); // the real text, not the Name
```
## Recipe 9 — Enable ONLY the module under test (deterministic, faster, isolated)
Pass `enableModules` to the base ctor so exactly those modules are on before launch — and for a
single-module suite, pass **just the one you're testing**. `ConfigureGlobalModuleSettings` enables the
named modules and **disables every other one**, so the runner boots only what you need:
```csharp
// All five ScreenRuler test classes do this; ColorPicker too. The key is the settings.json
// "enabled" name (note spaces, e.g. "Measure Tool", "PowerToys Run") — see the enabled section of
// %LocalAppData%\Microsoft\PowerToys\settings.json or ModuleConfigData.
public MyTests() : base(PowerToysModule.PowerToysSettings, enableModules: new[] { "Measure Tool" }) { }
```
Why it's worth doing on every per-module suite:
- **Faster on a fresh profile (CI).** The runner's `start_enabled_powertoys` phase starts each enabled
module; on a clean CI profile that's ~15 default-on modules (~10s). Enabling one cuts that to ~1s
(~9s saved per cold start). *(The hotkey register/unregister loop runs over all modules regardless,
so it's unchanged — the win is the start phase.)* Locally it's timing-neutral.
- **Isolated + deterministic.** No other module's global hotkey, overlay, or tray behavior can
interfere with your gesture, and the test starts from a known on/off baseline instead of whatever
`settings.json` happened to hold.
It's compatible with tests that toggle the module themselves (e.g. ColorPicker toggles OFF→ON to check
the process lifecycle) — the module just starts already-enabled.
For a per-module *setting* (not just enable/disable), edit the module's own settings file before launch:
```csharp
SettingsConfigHelper.UpdateModuleSettings(
"ColorPicker",
defaultSettingsContent: "{}",
settings => settings["copiedColorRepresentation"] = "HEX");
```
## Recipe 10 — Drive controls that live in a *different* window (process-scoped session)
A module's toolbar / overlay / editor is a separate window from Settings. The legacy `global: true`
Find reached into it implicitly; in `.Next` bind a session to that **process** and search there.
`Session.FromProcess` uses the `-a` (process) scope, so it resolves a control across whichever of the
process's windows owns it — ideal for a toolbar that may be one of several windows.
```csharp
// Screen Ruler's toolbar buttons live in PowerToys.MeasureToolUI, NOT the Settings window.
var ruler = Session.FromProcess("PowerToys.MeasureToolUI", PowerToysModule.ScreenRuler, timeoutMS: 5_000);
ruler.Find<Element>(By.AccessibilityId("Button_Spacing"), 15_000).Click();
```
> **Process name ≠ window title.** The Measure Tool's window *title* is `"PowerToys.ScreenRuler"`, but
> the *process* name winappcli's `-a` flag needs is `"PowerToys.MeasureToolUI"`. The authoritative
> process names are in the harness's `ModuleConfigData.cs`.
## Recipe 11 — Center the cursor before a coordinate measurement
```csharp
var size = System.Windows.Forms.SystemInformation.PrimaryMonitorSize; // PHYSICAL px when DPI-aware (Pitfall 12)
int cx = size.Width / 2, cy = size.Height / 2;
MouseHelper.MoveTo(cx, cy); // park at a known on-screen spot
MouseHelper.Drag(cx - 50, cy - 50, cx + 49, cy + 49); // 100x100 box centred on screen
```
Never anchor a gesture to the *current* cursor (`GetMousePosition() + 200`) — the cursor can be
anywhere (often near the bottom edge after a toolbar pops up), pushing the gesture off-screen and
producing a wrong/empty measurement. `System.Windows.Forms` flows transitively from the harness
(`UseWindowsForms=true`), so you can call `SystemInformation` without adding a reference.
**Move in steps so the overlay tracks the cursor.** A coordinate gesture must land on-screen, and the
module's overlay needs to see the cursor *move* before the click — a single `SetCursorPos` can land
without a tracked move, leaving the measurement empty. Park at a known on-screen point (screen-centre)
and move in a couple of steps:
```csharp
var (cx, cy) = ScreenCenter();
MouseHelper.MoveTo(cx - 60, cy - 60); // first move...
Thread.Sleep(200);
MouseHelper.MoveTo(cx, cy); // ...then settle on the target so the overlay is tracking
Thread.Sleep(400);
MouseHelper.LeftClick(); // or Drag(...) for a free-form box
```
---
## Pitfalls
1. **`Click` has no `msPreAction` in `.Next`.** Legacy `Click(msPreAction: 1000, msPostAction: 2000)`
`Thread.Sleep(1000); el.Click(msPostAction: 2000);`. Forgetting the pre-delay causes flaky clicks
on slow-rendering pages.
2. **`Click` (invoke) vs. `MouseClick` (real mouse).** `Click` uses UIA InvokePattern (and falls back
to Toggle/Select/Expand). For elements with **no** invoke pattern (TextBlocks, list labels, headers
whose ancestor handles the click), `Click` silently does nothing useful — use `MouseClick`.
3. **`By.Name` is a substring match and may return many hits.** Always `FindAll` + filter when the
name isn't unique. Prefer `By.AccessibilityId`.
4. **No `global` parameter.** If a legacy `Find(by, t, global: true)` reached into a popup/other
window, switch the session scope (`Session.FromProcess`) or discover the window via `WindowsFinder`.
5. **`PowerToysModule.FancyZone` was renamed to `FancyZonesEditor`.** Update the enum value.
6. **Don't launch overlay/utility module exes standalone.** Drive `ColorPicker`/`LightSwitch`/etc.
through the `PowerToysSettings` scope so the runner owns the hotkey and toggles; a standalone exe
has no runner behind it.
7. **`System.Threading.Timer` is ambiguous** in this harness (WinForms is referenced and also defines
`Timer`). Fully-qualify if you add one. (Rare in tests, common if you port harness-level code.)
8. **Cached element geometry is a snapshot.** Re-`Find` before using `X/Y/Width/Height` for a
drag/mouse-click if the UI moved since the lookup.
9. **Restore state you change.** Toggles, settings.json edits, and clipboard contents must be restored
in a `finally` so a failure mid-test doesn't poison the next one. Make cleanup tolerant
(`WindowControl.Try*`) so it never masks the real failure.
10. **First-build/NuGet errors** → run `tools\build\build-essentials.cmd` once before the per-project
build (or `dotnet restore <csproj> -p:Platform=x64`). A missing `project.assets.json` shows up as
`NETSDK1004`. Missing `Common.Dotnet.CsWinRT.props` import → CI's `verifyCommonProps.ps1` fails;
the template already includes it.
11. **`winapp.exe` missing at run time** is expected on a headless agent — the project still *builds*.
Don't treat a missing-CLI run failure as a migration defect; report build-clean + ready-to-run.
12. **Coordinate-exact tests need an `app.manifest` with `PerMonitorV2`.** Without it the test host is
DPI-unaware, so `MouseHelper`'s `SetCursorPos`/`GetCursorPos` coordinates are virtualized by the
display scale and stop matching winappcli's PHYSICAL-pixel bounds. On a 150% display a 99px drag
measured as ~149px (Screen Ruler reported `150 x 149` instead of `100 x 100`). Copy the manifest
from the module's legacy UITests project (or [templates/app.manifest](../templates/app.manifest))
and add `<ApplicationManifest>app.manifest</ApplicationManifest>` to the csproj. Regex-only
assertions (e.g. `\d+ x \d+`) don't notice the scale — only exact-value tests fail, which makes
this easy to miss.
**Why the legacy project's manifest doesn't save it:** a legacy `OutputType=Library` test runs
inside `testhost.exe` (vstest), whose manifest — not the test DLL's — governs DPI awareness, so the
legacy `app.manifest` is silently ignored and its coordinate-exact tests can't be DPI-correct on a
scaled display (the ScreenRuler legacy Bounds test fails `150 x 149` even *with* its manifest). A
`.Next` project is an `OutputType=Exe` (MTP), so ITS manifest applies to its own process — which is
why adding the manifest actually fixes the port, and can make it pass where the legacy can't.
13. **Anchor coordinate gestures to the screen centre, not the current cursor** (Recipe 11). This is
the #1 cause of "measurement is wrong/empty" — the cursor drifts to the bottom edge after a
toolbar appears.
14. **Global-hotkey activation is racy right after enabling a module.** The runner arms its keyboard
hook asynchronously, so the first chord is easily lost. Settle ~1.5s after the toggle, then
re-send the chord and poll for the window, for several attempts (SKILL Recipe 4; the ScreenRuler
`SendShortcutUntilVisible` helper is the reference).
15. **Per-test cold relaunch amplifies flakiness.** By default each `[TestMethod]` kills + relaunches
the runner, so every test pays the startup + hook-arming cost. For a suite of cheap cases against
one page, consider `ReuseScopeAcrossTests => true` (one launch per class). Content-dependent
measurements (spacing edge-detection) also vary with what's under the cursor — assert on **format**
(regex) unless the gesture is content-independent (a free-form drag like Bounds), where an exact
value is safe.
16. **Coordinate gestures break when the window/cursor is off-screen — and it only shows on CI.** A
`WindowSize` preset that resized but kept its old top-left could push the Settings window (and the
measurement area) partially off a same-sized 1920×1080 CI display, so the gesture landed off-screen
and nothing was captured (empty clipboard). It passed **locally** only because a higher-res dev
display left everything on-screen — so don't trust a local pass for coordinate tests. The harness
now **centers and clamps** `WindowSize` presets to ~90% of the display, keeping the window fully
on-screen; anchor gestures to `ScreenCenter()` (always on-screen) and move in steps (Recipe 11).
You do **not** need to minimize or move the covering window — an overlay module like the Measure
Tool captures the gesture even with the Settings window underneath (verified); the failure was the
off-screen position, not the window covering the centre.
17. **The first-run "Welcome to PowerToys" / "What's new" window appears on a fresh profile (CI) and
eats centre-screen gestures.** On a clean profile the runner opens the OOBE (Welcome) or SCOOBE
(what's-new) window — **centered and topmost** — so a coordinate measurement at screen-centre lands
on it instead of the module overlay (empty clipboard). It never shows on a dev box because your
profile already marked them seen — the *same* local-passes/CI-fails trap as Pitfall 16, and the
hardest to spot because the runner log still shows the hotkey firing and the module activating. The
harness now suppresses both in `PreTestHygiene` via
`SettingsConfigHelper.SuppressFirstRunExperience()` (seeds `oobe_settings.json`
`openedAtFirstLaunch=true` + `settings.json` `show_whats_new_after_updates=false`, mirroring the
runner's own gating). If you drive coordinate gestures and see "passes local, empty result on CI",
suspect a stray fresh-run window first.

View File

@@ -0,0 +1,187 @@
# Porting workflow
Two end-to-end playbooks. Pick the one matching your scenario (see SKILL.md "Pick your scenario").
Both assume you've read [framework-differences.md](framework-differences.md) and have
[api-mapping.md](api-mapping.md) open.
---
## Scenario A — Port existing legacy tests
Re-implement an existing `[Module].UITests` project (which references `UITestAutomation.csproj`) as a
new `[Module].UITests.Next` project (referencing `UITestAutomation.Next.csproj`), preserving every
test's **intent and assertions**.
### A0. Baseline the legacy suite first — ELEVATED (recommended)
Before porting, run the **legacy** suite once to learn its real local pass rate. **Run it elevated:**
the legacy harness launches PowerToys via `ProcessStartInfo { Verb = "runas" }`, so a non-elevated
test host can't complete the launch and **every test fails at startup with a misleading
`Win32Exception` cascade** — a false 0/N that looks like "the tests are broken" but is just the run
method. (This is exactly why VS Test Explorer passes them: VS runs as admin.) Don't conclude the
legacy suite is broken from a non-elevated run.
```pwsh
# 1. Build the legacy project (WinAppDriver-based, OutputType=Library).
tools\build\build.cmd -Path src\modules\<Module>\Tests\<Module>.UITests -Platform x64 -Configuration Debug
# 2. Run ELEVATED. Put the run in a .ps1 and launch it with -Verb RunAs (one UAC prompt) so the
# harness's runas launch has an elevated host. The script should start WinAppDriver + run vstest:
# $dll = "$PWD\x64\Debug\tests\<Module>.UITests\net10.0-windows10.0.26100.0\<Module>.UITests.dll"
# Start-Process "C:\Program Files (x86)\Windows Application Driver\WinAppDriver.exe" -ArgumentList "127.0.0.1","4723"
# vstest.console.exe $dll /Platform:x64 /InIsolation /Logger:"trx;LogFileName=legacy.trx" /ResultsDirectory:<dir>
# Have the script write a DONE marker at the end; poll for it, then read the .trx.
Start-Process pwsh -Verb RunAs -ArgumentList "-NoProfile","-ExecutionPolicy","Bypass","-File","<runner>.ps1"
```
Knowing the baseline tells you which failures are pre-existing product/environment issues you should
NOT expect the port to fix. A measurement failure on a scaled (non-100%) display is usually DPI (see
[patterns-and-pitfalls.md](patterns-and-pitfalls.md) Pitfall 12): the ScreenRuler legacy suite scores
**4/5** elevated here (Bounds fails at 150% scale), while the `.Next` port scores **5/5** — its Exe
`app.manifest` makes it DPI-aware where the legacy `Library` project's manifest is silently ignored.
### A1. Inventory the source
- List every `[TestClass]` and `[TestMethod]` in the legacy project. Note `[TestCategory]` tags,
`DataRow`s, and the base-ctor args (`scope`, `WindowSize`, `commandLineArgs`).
- List shared helpers (a `TestHelper`/`*Helpers` static class is common — ScreenRuler's
`TestHelper.cs` is the canonical example). Decide per-helper whether to **port it**, **inline it**,
or **drop it** (Selenium-only scaffolding usually drops).
- For each test, write a one-line statement of *what it asserts* (the behavior), independent of how the
legacy harness did it. You're re-creating that behavior, not the Selenium calls.
### A2. Map the structure
| Legacy piece | `.Next` target |
|---|---|
| `[TestClass] FooTests : UITestBase` | same shape, `using Microsoft.PowerToys.UITest.Next;` |
| ctor `: base(PowerToysSettings, WindowSize.Large)` | `: base(PowerToysModule.PowerToysSettings, WindowSize.Large)` |
| ctor `commandLineArgs: new[]{ "--enable", "Foo" }` | `enableModules: new[]{ "Foo" }` (deterministic module baseline) |
| `TestHelper.InitializeTest(this, …)` | a private setup method, or rely on `UITestBase` hygiene + an explicit nav helper |
| `[TestMethod("Foo.Bar")]` | `[TestMethod]` + keep `[TestCategory("Foo")]` |
### A3. Re-implement each test, method by method
For each legacy method:
1. **Translate the selectors** first (the highest-risk part). Replace `By.XPath`/`By.ClassName` per
[framework-differences.md §3](framework-differences.md). Prefer `By.AccessibilityId` — open the
module's XAML and find the `x:Name`/`AutomationProperties.AutomationId` the control exposes.
2. **Translate the actions** with [api-mapping.md](api-mapping.md). The frequent ones:
- `element.Click(msPreAction: N, …)` → if you relied on the pre-delay, add `Thread.Sleep(N)` then
`element.Click(msPostAction: …)` (`.Next` `Click` has no `msPreAction`).
- A click on a non-invokable element (TextBlock/ListItem whose ancestor handles it) →
`element.MouseClick(...)`.
- Selenium `Actions` drags → `element.Drag(...)` / `MouseHelper.Drag(...)`.
- `testBase.SendKeys(...)` / `Session.PerformMouseAction(...)``KeyboardHelper.*` / `MouseHelper.*`.
3. **Translate the waits.** Replace hand-rolled `while (DateTime.Now < end) { … Task.Delay(...) }`
poll loops with the built-ins: `element.WaitForProperty("ToggleState","On",t)`,
`element.WaitForValue(...)`, `Session.WaitForElement(by,t)`, `Session.WaitFor(() => …, t)`, or
`ClipboardHelper.WaitForText(...)`. Keep a custom poll only when you're polling something with no
built-in (e.g. `Process.GetProcessesByName(...)` — see the ColorPicker `WaitForProcess` helper).
4. **Translate cleanup.** Delete manual `CloseOtherApplications`/`Win+M` (the base does hygiene). For
windows the *test* spawned (overlay/editor), close them in a `finally` with
`WindowControl.TryCloseByApp("PowerToys.<Module>UI")`. Restore any toggle you flipped to its
initial state in a `finally` (see the ColorPicker example's nested `finally`).
5. **Keep the assertions identical in spirit** — same things checked, same pass/fail meaning.
### A4. Port shared helpers thoughtfully
- Start from [../templates/TestHelper.cs](../templates/TestHelper.cs) — it already implements the
common building blocks (navigate, toggle + verify process, read shortcut, discover/activate/close
the module window, clipboard, screen-center) with the right `.Next` idioms; map your legacy helper's
module-specific bits onto it rather than translating Selenium scaffolding line-by-line.
- A static `TestHelper` is fine to keep, but re-point it at the new APIs. Drop members that only
existed to work around Selenium (manual `Session.Attach` dances, STA-clipboard wrappers → use
`ClipboardHelper`).
- Shortcut-string parsing helpers (`ParseShortcutText` turning `"Win + Shift + C"` into `Key[]`) port
almost verbatim — just map `"win"``Key.LWin`. Both examples include this parser; reuse it.
### A5. Validate (write → build → run → iterate)
Build to exit 0 (see [project-setup.md §5](project-setup.md)). Then map each new `[TestMethod]` back
to the legacy method it replaces and confirm none were dropped. On a live desktop, **run in a loop**:
start with one deterministic test (the activation/toggle test), get it green, then widen to the whole
suite. UI runs expose environment-real failures that only show up live — DPI scaling, cursor drift,
and hotkey-arming races (all hit during the ScreenRuler port; see
[patterns-and-pitfalls.md](patterns-and-pitfalls.md) Pitfalls 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

@@ -0,0 +1,173 @@
# Project setup & scaffolding
How to create, place, name, register, and build the new `.Next` test project. The starter files live
in [../templates/](../templates/).
## 1. Decide the name and location
| Scenario | Project name | Folder |
|---|---|---|
| **A — Port** (legacy UI tests exist) | `[Module].UITests.Next` | `src/modules/[Module]/Tests/[Module].UITests.Next/` |
| **B — Greenfield** (no UI tests) | `[Module].UITests` | `src/modules/[Module]/Tests/[Module].UITests/` |
Rules and judgment:
- **The `.Next` suffix exists only to avoid colliding with an existing legacy project.** If there is
nothing to live alongside (Scenario B), drop it.
- **Match the module's existing test layout.** Many modules already nest tests under a `Tests/`
folder (`MeasureTool/Tests/ScreenRuler.UITests`, `LightSwitch/Tests/LightSwitch.UITests`); others
put the UI-tests project directly under the module root (`colorPicker/ColorPicker.UITests`,
`fancyzones/FancyZones.UITests`). **Mirror whatever the module already does** — don't invent a new
structure. The path-segment count only changes the relative `..\` depth to `common\` in the csproj.
- Keep the **`AssemblyName`** matching the project name (`[Module].UITests.Next`) so logs and build
artifacts are unambiguous; there's no need to strip the `.Next` from the assembly name.
- If the legacy project has an unusual file name (e.g. `HostsEditor.UITests.csproj` inside a
`Hosts.UITests/` folder), prefer a clean `[Module].UITests.Next.csproj`; consistency with the new
examples (`ColorPicker.UITests.csproj`, `Settings.UITests.csproj`) wins.
## 2. Scaffold the csproj
Copy [../templates/Module.UITests.Next.csproj](../templates/Module.UITests.Next.csproj) and replace the
`__MODULE__` placeholder (and fix the `..\` depth on the ProjectReference). The reference csproj
(ColorPicker, whose project folder sits 3 levels under `src/`) is:
```xml
<Project Sdk="Microsoft.NET.Sdk">
<!-- Look at Directory.Build.props in root for common stuff as well -->
<Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" />
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0-windows10.0.26100.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<RootNamespace>Microsoft.__MODULE__.UITests</RootNamespace>
<AssemblyName>__MODULE__.UITests.Next</AssemblyName>
<!-- Microsoft.Testing.Platform: appears in Test Explorer AND runs via dotnet test / vstest. -->
<IsTestingPlatformApplication>true</IsTestingPlatformApplication>
<EnableMSTestRunner>true</EnableMSTestRunner>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
<!-- UI tests need a live desktop; never run them as part of MSBuild. -->
<RunVSTest>false</RunVSTest>
</PropertyGroup>
<!-- Stage the built test app under <Platform>\<Configuration>\tests\ so the UI-tests build
pipeline (CopyFiles glob **/<plat>/<config>/tests/**) picks it up. -->
<PropertyGroup>
<OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\tests\__MODULE__.UITests.Next\</OutputPath>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MSTest" />
</ItemGroup>
<ItemGroup>
<!-- Adjust the ..\ depth to reach src\common from THIS project's folder. -->
<ProjectReference Include="..\..\..\common\UITestAutomation.Next\UITestAutomation.Next.csproj" />
</ItemGroup>
</Project>
```
Critical, non-negotiable bits (CI audits or the build will fail without them):
1. **`<Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" />`** immediately after the
`<Project Sdk=...>` line. `.pipelines/verifyCommonProps.ps1` requires it on every `src/**` csproj.
2. **`OutputType=Exe`**, **`IsTestingPlatformApplication=true`**, **`EnableMSTestRunner=true`** — the
Microsoft.Testing.Platform runner the rest of the repo uses; this is what makes the class appear in
Test Explorer and run via `dotnet test`/`vstest.console.exe`.
3. **`<OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\tests\<Name>\</OutputPath>`** — stages the
build output where the UI-tests pipeline globs (`**/<plat>/<config>/tests/**`). Without it the app
builds to `bin\` and is never picked up by the test job.
4. **`RunVSTest=false`** — UI tests must not run during MSBuild.
5. **ProjectReference to `UITestAutomation.Next.csproj` only** — never the legacy
`UITestAutomation.csproj`. Fix the `..\` depth to match the folder nesting:
- `src/modules/<M>/Tests/<M>.UITests.Next/` (4 levels under `src`) → `..\..\..\..\common\UITestAutomation.Next\UITestAutomation.Next.csproj`
- `src/modules/<M>/<M>.UITests/` (3 levels under `src`) → `..\..\..\common\UITestAutomation.Next\UITestAutomation.Next.csproj`
- `src/settings-ui/<M>.UITests/` (2 levels under `src`) → `..\..\common\UITestAutomation.Next\UITestAutomation.Next.csproj`
> Use `MSTest` (the meta-package) for a test **Exe**, matching the ColorPicker/Settings examples — not
> the bare `MSTest.TestFramework` the harness library itself uses.
## 3. Register in `PowerToys.slnx`
Add the project to [../../../../PowerToys.slnx](../../../../PowerToys.slnx) inside the module's
`<Folder>`, right next to the legacy project (Scenario A) so they're visually paired:
```xml
<Project Path="src/modules/<Module>/Tests/<Module>.UITests.Next/<Module>.UITests.Next.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
```
Match the `<Platform>` mapping block of the sibling projects in the same folder (every UI-tests entry
uses the `*|ARM64 → ARM64` / `*|x64 → x64` pair shown above).
## 4. Add the test class(es) and shared helper
Copy [../templates/ModuleEndToEndTests.cs](../templates/ModuleEndToEndTests.cs) into the project,
rename it to `[Module]EndToEndTests.cs` (or keep the legacy test-class names in Scenario A), and start
filling in test methods.
For anything beyond a single trivial test, also copy
[../templates/TestHelper.cs](../templates/TestHelper.cs) — a static helper with the reusable building
blocks every port needs (navigate to the page, toggle + verify the process, read the activation
shortcut, discover/activate/close the module window with patient retry, clipboard, screen-center).
Fill in the `__MODULE__` / `__MODULEUI__` / AutomationId placeholders and delete what you don't use.
This mirrors how the legacy suites are organized (a `TestHelper` + thin test classes) and is exactly
the shape of the validated ScreenRuler port.
The standard file header is required on every `.cs`:
```csharp
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
```
## 4b. (Coordinate-exact tests only) add a DPI-aware `app.manifest`
If any test drives the mouse by **pixel coordinates** and asserts on an **exact** value (a drag that
must measure `100 x 100`, a click at a precise point), the test host MUST be per-monitor DPI aware,
otherwise `MouseHelper`'s `SetCursorPos`/`GetCursorPos` are virtualized by the display scale and stop
matching winappcli's physical-pixel bounds (a 99px drag measured ~149px on a 150% display).
Copy [../templates/app.manifest](../templates/app.manifest) into the project (or the one from the
module's legacy UITests project) and reference it in the csproj:
```xml
<PropertyGroup>
<ApplicationManifest>app.manifest</ApplicationManifest>
</PropertyGroup>
```
Tests that only assert on **format** (regex like `\d+ x \d+`) or never touch raw coordinates don't
need the manifest — which is why ColorPicker/Settings `.Next` projects omit it.
## 5. Build & run
```pwsh
# 0. FIRST build of a new project: restore so project.assets.json exists (else NETSDK1004).
dotnet restore src\modules\<Module>\Tests\<Module>.UITests.Next\<Module>.UITests.Next.csproj -p:Platform=x64
# (or run tools\build\build-essentials.cmd once at the start of the session.)
# 1. Build only this project (fast). Exit code 0 = success.
tools\build\build.cmd -Path src\modules\<Module>\Tests\<Module>.UITests.Next -Platform x64 -Configuration Debug
# 2. Run (needs a live desktop + winapp.exe). A .Next project is a Microsoft.Testing.Platform Exe,
# so run the produced exe directly (Test Explorer also works). Filter + TRX report for a tight loop:
$exe = "$PWD\x64\Debug\tests\<Module>.UITests.Next\net10.0-windows10.0.26100.0\<Module>.UITests.Next.exe"
& $exe --filter "TestCategory=<Cat>" --report-trx --report-trx-filename run.trx --results-directory .\TestResults\<Module>
# --filter accepts "TestCategory=X" or "FullyQualifiedName~Y"; omit it to run everything. Exit 0 = all passed.
```
- On build failure, read `build.<Configuration>.<Platform>.errors.log` next to the project.
- `winapp.exe` is a **run-time** prerequisite only (`winget install Microsoft.winappcli`, or set
`WINAPP_CLI_PATH`). A migration that compiles clean is valid even where the CLI/desktop is absent;
say so and list coverage.
- `dotnet test` also works for a one-shot run, but prefer the produced exe for a fast iterate loop and
do **not** run UI tests from inside an MSBuild step — they need an interactive session.

View File

@@ -0,0 +1,54 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
TEMPLATE — copy into src/modules/<Module>/Tests/<Module>.UITests.Next/ and replace:
__MODULE__ -> the module name used for the project/assembly (e.g. ColorPicker, ScreenRuler)
the ProjectReference ..\ depth -> enough ..\ to reach src\common from THIS folder
(see references/project-setup.md §2)
For a GREENFIELD project (module had no UI tests), rename the file and AssemblyName to drop
the ".Next" suffix (use <Module>.UITests).
-->
<!-- REQUIRED: must be the first line after <Project>. CI (verifyCommonProps.ps1) audits this. -->
<Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" />
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0-windows10.0.26100.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<RootNamespace>Microsoft.__MODULE__.UITests</RootNamespace>
<AssemblyName>__MODULE__.UITests.Next</AssemblyName>
<!--
Microsoft.Testing.Platform: the modern runner Directory.Build.props enables repo-wide, so this
test class appears in Test Explorer AND can be run via `dotnet test` / `vstest.console.exe`.
-->
<IsTestingPlatformApplication>true</IsTestingPlatformApplication>
<EnableMSTestRunner>true</EnableMSTestRunner>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
<!-- UI tests need a live desktop; never run them as part of MSBuild. -->
<RunVSTest>false</RunVSTest>
</PropertyGroup>
<!--
Stage the built test app under <Platform>\<Configuration>\tests\ so the UI-tests build pipeline
(CopyFiles glob **/<plat>/<config>/tests/**) picks it up, matching the other *.UITests projects.
Without this it builds to bin\ and is never staged into the artifact.
-->
<PropertyGroup>
<OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\tests\__MODULE__.UITests.Next\</OutputPath>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MSTest" />
</ItemGroup>
<ItemGroup>
<!-- Reference the NEW harness only. NEVER reference ..\common\UITestAutomation\UITestAutomation.csproj.
Adjust the ..\ depth so it resolves from this project's folder to src\common. -->
<ProjectReference Include="..\..\..\..\common\UITestAutomation.Next\UITestAutomation.Next.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,144 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
// TEMPLATE — a starting scaffold for a `.Next` UI-test class. Replace __MODULE__ / __MODULEUI__ /
// selectors with the real values for your module, delete what you don't need, and add test methods.
// See the skill's references/patterns-and-pitfalls.md for the full recipe catalog and
// ColorPickerEndToEndTests.cs for a complete worked example.
using System.Diagnostics;
using Microsoft.PowerToys.UITest.Next;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.__MODULE__.UITests;
[TestClass]
public class __MODULE__EndToEndTests : UITestBase
{
// Drive overlay/utility modules through the Settings scope so the runner owns the activation
// hotkey and module toggles. `enableModules` enables ONLY the listed modules (disabling the rest)
// before launch — pass just the one under test so the runner boots a single module (faster on a
// fresh CI profile + isolated from other modules' hotkeys/overlays). The name is the settings.json
// "enabled" key (note spaces, e.g. "Measure Tool", "PowerToys Run"). Add a WindowSize if needed.
public __MODULE__EndToEndTests()
: base(PowerToysModule.PowerToysSettings, enableModules: new[] { "__MODULE_SETTINGS_KEY__" })
{
}
[TestMethod]
[TestCategory("__MODULE__")]
public void ExampleScenario()
{
try
{
RunTest();
}
finally
{
// Tolerant cleanup — close any window the test spawned, then Settings. Never throws, so it
// can't mask the real failure.
WindowControl.TryCloseByApp("__MODULEUI__");
WindowControl.TryCloseByApp("PowerToys.Settings");
}
}
private void RunTest()
{
// 1. Navigate to the module's Settings page (adjust selector / nav-item id for your module).
// Some pages use a left-nav NavigationViewItem by AutomationId; others a dashboard label.
// Session.Find<NavigationViewItem>(By.AccessibilityId("__MODULE__NavItem")).Click(msPostAction: 500);
// 2. Find the page enable toggle and verify the module process follows it.
var toggle = Find<ToggleSwitch>(By.Name("__MODULE__"));
bool initialIsOn = toggle.IsOn;
try
{
if (!toggle.IsOn)
{
toggle.Toggle(true);
Assert.IsTrue(toggle.WaitForProperty("ToggleState", "On", 5_000), "Toggle didn't turn On.");
Assert.IsTrue(WaitForProcess("__MODULEUI__", expected: true, 10_000), "Process didn't start.");
}
// 3. Read the activation shortcut from the ShortcutControl's EditButton (HelpText carries
// the readable chord, e.g. "Win + Shift + C").
var editButton = Find<Button>(By.AccessibilityId("EditButton"));
Key[] keys = ParseShortcutText(editButton.HelpText);
Assert.IsTrue(keys.Length > 0, $"Could not parse shortcut '{editButton.HelpText}'.");
// 4. Fire the hotkey (retry — the runner arms its hook asynchronously) and wait for the
// module window/overlay to appear.
Session? appWindow = null;
for (int attempt = 1; attempt <= 3 && appWindow is null; attempt++)
{
KeyboardHelper.SendKeys(keys);
appWindow = WindowsFinder.WaitForWindowByApp("__MODULEUI__", _ => true, timeoutMS: 2_500);
}
Assert.IsNotNull(appWindow, "Module window did not appear after firing the shortcut.");
// 5. ... assert on the module's UI (read values, click, inspect tree, check clipboard) ...
TestContext.WriteLine($"Module window appeared: hwnd={appWindow!.WindowHandle}");
}
finally
{
// Restore the toggle to its initial state, tolerantly.
try
{
if (toggle.IsOn != initialIsOn)
{
toggle.Toggle(initialIsOn);
}
}
catch
{
}
}
}
/// <summary>Poll for a process becoming present/absent (no built-in wait for this).</summary>
private static bool WaitForProcess(string name, bool expected, int timeoutMS)
{
var deadline = DateTime.UtcNow + TimeSpan.FromMilliseconds(timeoutMS);
while (DateTime.UtcNow < deadline)
{
if ((Process.GetProcessesByName(name).Length > 0) == expected)
{
return true;
}
Thread.Sleep(250);
}
return false;
}
/// <summary>Parse a UI shortcut string like "Win + Shift + C" into the Key chord.</summary>
private static Key[] ParseShortcutText(string shortcutText)
{
var parts = shortcutText.Split(new[] { " + ", "+", " " }, StringSplitOptions.RemoveEmptyEntries);
var keys = new List<Key>();
foreach (var raw in parts)
{
var part = raw.Trim().ToLowerInvariant();
Key? key = part switch
{
"win" or "windows" => Key.LWin,
"ctrl" or "control" => Key.Ctrl,
"shift" => Key.Shift,
"alt" => Key.Alt,
_ when part.Length == 1 && part[0] >= 'a' && part[0] <= 'z' =>
(Key)Enum.Parse(typeof(Key), part.ToUpperInvariant()),
_ => null,
};
if (key.HasValue)
{
keys.Add(key.Value);
}
}
return keys.ToArray();
}
}

View File

@@ -0,0 +1,218 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
// TEMPLATE — a static helper for a `.Next` UI-test project, distilled from the validated ScreenRuler
// port. Copy alongside ModuleEndToEndTests.cs, then:
// • Replace __MODULE__ (project name) and __MODULEUI__ (the module's PROCESS name, e.g.
// "PowerToys.MeasureToolUI" — NOT the window title; see ModuleConfigData.cs in the harness).
// • Fill in the AutomationIds for your module's nav item(s), toggle, and shortcut card from the
// module's XAML (or discover them live: `winapp ui search "<id>" -a PowerToys.Settings --json`).
// • Delete the helpers you don't need. Keep each helper ADAPTABLE — every module is different.
// See references/patterns-and-pitfalls.md for the full recipe catalog these are based on.
using Microsoft.PowerToys.UITest.Next;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.__MODULE__.UITests;
public static class TestHelper
{
// ── Customize: AutomationIds + process name ───────────────────────────────────────────────
// The module's PROCESS name (winappcli -a). Window TITLE may differ — use the process name.
public const string ModuleProcess = "__MODULEUI__";
// Left-nav item AutomationId for the module's Settings page, and its parent group (if the item
// lives under a collapsible group like "System Tools"). Set ParentNavItemId to null if there's none.
public const string NavItemId = "__MODULE__NavItem";
public const string? ParentNavItemId = "SystemToolsNavItem";
// The page enable ToggleSwitch and the ShortcutControl card AutomationIds.
public const string ToggleId = "Toggle___MODULE__";
public const string ShortcutCardId = "Shortcut___MODULE__";
// ── Navigation ────────────────────────────────────────────────────────────────────────────
/// <summary>Navigate to the module's Settings page (expanding its parent nav group if needed).</summary>
public static void NavigateToPage(UITestBase testBase)
{
// A collapsible parent group hides its children until expanded; expand only when the child
// isn't already in the tree (re-clicking an expanded group would collapse it).
if (ParentNavItemId is not null && !testBase.Session.Has(By.AccessibilityId(NavItemId), 500))
{
testBase.Session.Find<NavigationViewItem>(By.AccessibilityId(ParentNavItemId), 5000).Click(msPostAction: 500);
}
testBase.Session.Find<NavigationViewItem>(By.AccessibilityId(NavItemId), 5000).Click(msPostAction: 800);
}
// ── Toggle ────────────────────────────────────────────────────────────────────────────────
/// <summary>Set the page enable toggle and wait for the UI to reflect the new state.</summary>
public static ToggleSwitch SetToggle(UITestBase testBase, bool enable)
{
var toggle = testBase.Session.Find<ToggleSwitch>(By.AccessibilityId(ToggleId), 5000);
toggle.Toggle(enable);
toggle.WaitForProperty("ToggleState", enable ? "On" : "Off", 5000);
return toggle;
}
/// <summary>Set the toggle and assert it (and optionally the module process) reached the state.</summary>
public static void SetAndVerifyToggle(UITestBase testBase, bool enable, bool verifyProcess = false, int timeoutMs = 10_000)
{
var toggle = SetToggle(testBase, enable);
Assert.AreEqual(enable, toggle.IsOn, $"Toggle should be {(enable ? "On" : "Off")}.");
if (verifyProcess)
{
Assert.IsTrue(
WaitForProcess(ModuleProcess, expected: enable, timeoutMs),
$"Process '{ModuleProcess}' should be {(enable ? "running" : "stopped")} after toggling.");
}
}
// ── Activation shortcut ───────────────────────────────────────────────────────────────────
/// <summary>Read the activation shortcut from the ShortcutControl's EditButton HelpText.</summary>
public static Key[] ReadActivationShortcut(UITestBase testBase)
{
var card = testBase.Session.Find<Element>(By.AccessibilityId(ShortcutCardId), 5000);
var editButton = card.Find<Element>(By.AccessibilityId("EditButton"), 5000);
return ParseShortcutText(editButton.HelpText);
}
/// <summary>Parse "Win + Ctrl + Shift + M" into a Key chord (note: "win" maps to <see cref="Key.LWin"/>).</summary>
public static Key[] ParseShortcutText(string shortcutText)
{
var keys = new List<Key>();
if (string.IsNullOrEmpty(shortcutText))
{
return keys.ToArray();
}
foreach (var raw in shortcutText.Split(new[] { " + ", "+", " " }, StringSplitOptions.RemoveEmptyEntries))
{
var part = raw.Trim().ToLowerInvariant();
Key? key = part switch
{
"win" or "windows" => Key.LWin,
"ctrl" or "control" => Key.Ctrl,
"shift" => Key.Shift,
"alt" => Key.Alt,
_ when part.Length == 1 && part[0] >= 'a' && part[0] <= 'z' =>
(Key)Enum.Parse(typeof(Key), part.ToUpperInvariant()),
_ => null,
};
if (key.HasValue)
{
keys.Add(key.Value);
}
}
return keys.ToArray();
}
// ── Module window lifecycle ───────────────────────────────────────────────────────────────
/// <summary>True when at least one of the module's windows is open.</summary>
public static bool IsModuleUIOpen() => WindowsFinder.ListByApp(ModuleProcess).Count > 0;
/// <summary>Poll until the module UI reaches the requested presence.</summary>
public static bool WaitForModuleUIState(bool shouldBeOpen, int timeoutMs = 5000, int pollMs = 100)
{
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
while (DateTime.UtcNow < deadline)
{
if (IsModuleUIOpen() == shouldBeOpen)
{
return true;
}
Thread.Sleep(pollMs);
}
return false;
}
public static bool WaitForModuleUI(int timeoutMs = 5000) => WaitForModuleUIState(true, timeoutMs);
public static bool WaitForModuleUIToDisappear(int timeoutMs = 5000) => WaitForModuleUIState(false, timeoutMs);
/// <summary>
/// Send the activation chord, retrying until the module UI appears. The runner arms its keyboard
/// hook asynchronously after the module is enabled, so the first chord is easily lost — settle
/// first, then retry (see Recipe 4 / Pitfall 14).
/// </summary>
public static bool SendShortcutUntilVisible(UITestBase testBase, Key[] activationKeys, int attempts = 5, int perAttemptMs = 3000)
{
Thread.Sleep(1500); // let the just-enabled module register its global hotkey
for (int i = 0; i < attempts; i++)
{
KeyboardHelper.SendKeys(activationKeys);
if (WaitForModuleUI(perAttemptMs))
{
return true;
}
}
return false;
}
/// <summary>
/// Activate the module via its shortcut and return a PROCESS-scoped session for its window(s).
/// Process scope (<see cref="Session.FromProcess"/>) resolves controls across whichever of the
/// module's windows owns them — the winappcli equivalent of the legacy <c>global: true</c> Find.
/// </summary>
public static Session ActivateModule(UITestBase testBase, Key[] activationKeys, string testName)
{
ClipboardHelper.Clear();
Assert.IsTrue(
SendShortcutUntilVisible(testBase, activationKeys),
$"Module UI should appear after the activation shortcut for {testName}: {string.Join(" + ", activationKeys)}");
return Session.FromProcess(ModuleProcess, PowerToysModule.PowerToysSettings, timeoutMS: 5000);
}
/// <summary>Close the module UI if open (best-effort, tolerant — safe in a finally).</summary>
public static void CloseModuleUI(UITestBase testBase)
{
if (!IsModuleUIOpen())
{
return;
}
// Prefer an in-UI Close button if the module has one; otherwise WM_CLOSE every window.
// try { Session.FromProcess(ModuleProcess).Find<Element>(By.AccessibilityId("Button_Close"), 2000).Click(); } catch { }
WindowControl.TryCloseByApp(ModuleProcess);
}
// ── Utilities ─────────────────────────────────────────────────────────────────────────────
/// <summary>Poll for a process becoming present/absent (no built-in wait for this).</summary>
public static bool WaitForProcess(string processName, bool expected, int timeoutMs)
{
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
while (DateTime.UtcNow < deadline)
{
if ((System.Diagnostics.Process.GetProcessesByName(processName).Length > 0) == expected)
{
return true;
}
Thread.Sleep(250);
}
return false;
}
/// <summary>
/// Primary-monitor centre in PHYSICAL pixels — the right anchor for coordinate gestures (don't
/// offset from the current cursor, which can be off-screen). Correct only when the test host is
/// per-monitor DPI aware (add the app.manifest, Pitfall 12); otherwise the size is virtualized.
/// </summary>
public static (int X, int Y) ScreenCenter()
{
var size = System.Windows.Forms.SystemInformation.PrimaryMonitorSize;
return (size.Width / 2, size.Height / 2);
}
}

View File

@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
TEMPLATE app.manifest for a .Next UI-test project.
ADD THIS ONLY for projects with COORDINATE-EXACT tests (mouse drag/click asserting on exact
pixel/measurement values, e.g. Screen Ruler's Bounds "100 x 100"). Without PerMonitorV2 the test
host is DPI-unaware and MouseHelper's SetCursorPos/GetCursorPos coordinates are virtualized by the
display scale factor, so they no longer match the PHYSICAL pixels winappcli reports (a 99px drag
measured ~149px on a 150% display).
Wire it into the csproj:
<PropertyGroup>
<ApplicationManifest>app.manifest</ApplicationManifest>
</PropertyGroup>
Replace __MODULE__ in the assemblyIdentity name.
-->
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="__MODULE__.UITests.Next.app"/>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- Windows 10+ feature support for unpackaged apps. -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
</windowsSettings>
</application>
</assembly>

View File

@@ -0,0 +1,70 @@
[CmdletBinding()]
Param(
# Target architecture: 'x64' or 'arm64'. Defaults to the pipeline's BuildPlatform variable.
[string]$Platform = $env:BuildPlatform
)
$ProgressPreference = 'SilentlyContinue'
$ErrorActionPreference = 'Stop'
# Pinned to the winappcli version the UITestAutomation.Next harness is validated against. Using
# the standalone CLI zip (rather than the MSIX / winget) keeps this working on agents that lack
# the App Installer and avoids MSIX registration entirely.
$Version = 'v0.3.2'
switch ($Platform)
{
'arm64'
{
$Asset = 'winappcli-arm64.zip'
$ExpectedHash = 'dfe9d6eb70618665e4adcee989be8ecd076bfd387714a35a5b38597196fed093'
}
default
{
$Asset = 'winappcli-x64.zip'
$ExpectedHash = '231373a4605ce7749172a70534ebab9305f91116e7f68d25cc73051372a6c579'
}
}
$DownloadUrl = "https://github.com/microsoft/winappCli/releases/download/$Version/$Asset"
$ZipPath = Join-Path $env:Temp $Asset
$InstallDir = Join-Path $env:Temp 'winappcli'
Write-Host "Downloading winappcli $Version ($Asset) from $DownloadUrl"
Invoke-WebRequest -Uri $DownloadUrl -OutFile $ZipPath
# Verify the download against the published SHA256 before trusting it.
$Hash = (Get-FileHash -Algorithm SHA256 $ZipPath).Hash
if ($Hash -ne $ExpectedHash)
{
throw "$Asset has unexpected SHA256 hash: $Hash (expected $ExpectedHash)"
}
# Fresh extract each run so a stale copy can't shadow the pinned version.
if (Test-Path $InstallDir)
{
Remove-Item $InstallDir -Recurse -Force
}
Expand-Archive -Path $ZipPath -DestinationPath $InstallDir -Force
# Clear Mark-of-the-Web in case the agent applied it, so the CLI runs non-interactively.
Get-ChildItem -Path $InstallDir -Recurse | Unblock-File -ErrorAction SilentlyContinue
$winapp = Get-ChildItem -Path $InstallDir -Recurse -Filter 'winapp.exe' | Select-Object -First 1 -ExpandProperty FullName
if (-not $winapp)
{
throw "winapp.exe was not found after extracting $Asset to $InstallDir."
}
Write-Host "winappcli installed at: $winapp"
# The harness (WinappCli.TryResolveExecutable) checks WINAPP_CLI_PATH first; also prepend the
# folder to PATH so any other consumer in later steps resolves winapp.exe too.
Write-Host "##vso[task.setvariable variable=WINAPP_CLI_PATH]$winapp"
Write-Host "##vso[task.prependpath]$(Split-Path -Parent $winapp)"
& $winapp --version
if ($LASTEXITCODE -ne 0)
{
throw "winapp.exe failed to run ('--version' exited with $LASTEXITCODE)."
}

View File

@@ -1,15 +1,20 @@
param(
[Parameter()]
[ValidateSet("Machine", "PerUser")]
[string]$InstallMode = "Machine"
[string]$InstallMode = "Machine",
# Folder that contains the PowerToys installer. Defaults to the build staging directory used
# by the official-build path (installer downloaded via DownloadPipelineArtifact@2). The
# full-build (buildNow) path passes the downloaded pipeline-artifact folder instead, since
# the installer ships inside that build's own artifact.
[Parameter()]
[string]$ArtifactPath = $ENV:BUILD_ARTIFACTSTAGINGDIRECTORY
)
$ProgressPreference = 'SilentlyContinue'
# Get artifact path
$ArtifactPath = $ENV:BUILD_ARTIFACTSTAGINGDIRECTORY
if (-not $ArtifactPath) {
throw "BUILD_ARTIFACTSTAGINGDIRECTORY environment variable not set"
throw "Installer path not provided. Pass -ArtifactPath or set BUILD_ARTIFACTSTAGINGDIRECTORY."
}
# Since we only download PowerToysSetup-*.exe files, we can directly find it

View File

@@ -171,6 +171,11 @@ jobs:
fetchTags: false
fetchDepth: 1
# Checkout to surface a missing import before full build.
- pwsh: |-
& '.pipelines/verifyCommonProps.ps1' -sourceDir '$(build.sourcesdirectory)\src'
displayName: Audit shared common props for CSharp projects in src sub-folder
- ${{ if eq(parameters.enableMsBuildCaching, true) }}:
- pwsh: |-
$MSBuildCacheParameters = ""
@@ -464,11 +469,6 @@ jobs:
flattenFolders: True
OverWrite: True
# Check if all projects (located in src sub-folder) import common props
- pwsh: |-
& '.pipelines/verifyCommonProps.ps1' -sourceDir '$(build.sourcesdirectory)\src'
displayName: Audit shared common props for CSharp projects in src sub-folder
# Check if deps.json files don't reference different dll versions.
- pwsh: |-
& '.pipelines/verifyDepsJsonLibraryVersions.ps1' -targetDir '$(build.sourcesdirectory)\$(BuildPlatform)\$(BuildConfiguration)'

View File

@@ -90,15 +90,28 @@ jobs:
reg add "HKLM\Software\Policies\Microsoft\Edge\WebView2\ReleaseChannels" /v PowerToys.exe /t REG_SZ /d "3"
displayName: "Enable WebView2 Canary Channel"
- ${{ if ne(parameters.platform, 'arm64') }}:
- download: current
displayName: Download artifacts
artifact: $(TestArtifactsName)
patterns: |-
**
!**\*.pdb
!**\*.lib
- ${{ if eq(parameters.buildSource, 'buildNowSlim') }}:
# buildNowSlim: the full build publishes the entire ~14 GB tree, but slim runs against the
# installed product, so fetch only the installer + the staged test binaries from this run's
# full-build artifact.
# IMPORTANT: DownloadPipelineArtifact's itemPattern downloads a file that matches ANY pattern,
# and '!' lines do NOT exclude. Per the task docs they "include files that don't match any
# include pattern", so a '!**/*.pdb' line pulls in every non-pdb file — i.e. the whole product
# tree (~14 GB). Use INCLUDE-ONLY patterns so only the installer + tests folder transfer.
- task: DownloadPipelineArtifact@2
displayName: Download artifacts (slim)
inputs:
buildType: 'current'
artifactName: $(TestArtifactsName)
targetPath: '$(Pipeline.Workspace)/$(TestArtifactsName)'
patterns: |
**/PowerToysSetup*.exe
**/tests/**
- ${{ else }}:
# buildNow (whole tree, run in place) and the official path (small tests-only artifact) both
# download the full named artifact via the Azure CLI ArtifactTool (bulk dedup, parallel). The
# x64 CLI zip runs natively on x64 and under emulation on arm64, so one path serves every arch
# and avoids the arm64 OOM the pipeline task hits on the large full-build artifact.
- template: steps-download-artifacts-with-azure-cli.yml
parameters:
artifactName: $(TestArtifactsName)
@@ -106,13 +119,20 @@ jobs:
- template: steps-ensure-dotnet-version.yml
parameters:
sdk: true
version: '9.0'
version: '10.0'
- pwsh: |-
& '$(build.sourcesdirectory)\.pipelines\InstallWinAppDriver.ps1'
displayName: Download and install WinAppDriver
- ${{ if ne(parameters.buildSource, 'buildNow') }}:
# winappcli (winapp.exe) powers the Microsoft.PowerToys.UITest.Next harness and isn't baked
# into the agent image yet. winget / App Installer isn't available on these agents, so download
# the pinned standalone CLI from its GitHub release. Drop this step once the CLI is pre-staged.
- pwsh: |-
& '$(build.sourcesdirectory)\.pipelines\InstallWinAppCli.ps1' -Platform '$(BuildPlatform)'
displayName: Download and install winappcli (winapp.exe)
- ${{ if and(ne(parameters.buildSource, 'buildNow'), ne(parameters.buildSource, 'buildNowSlim')) }}:
- task: DownloadPipelineArtifact@2
inputs:
buildType: 'specific'
@@ -133,7 +153,7 @@ jobs:
patterns: |
**/PowerToysSetup*.exe
- ${{ if ne(parameters.buildSource, 'buildNow') }}:
- ${{ if and(ne(parameters.buildSource, 'buildNow'), ne(parameters.buildSource, 'buildNowSlim')) }}:
- ${{ if eq(parameters.installMode, 'peruser') }}:
- pwsh: |-
& "$(build.sourcesdirectory)\.pipelines\installPowerToys.ps1" -InstallMode "PerUser"
@@ -144,12 +164,137 @@ jobs:
& "$(build.sourcesdirectory)\.pipelines\installPowerToys.ps1" -InstallMode "Machine"
displayName: Install PowerToys (Machine-Level)
# buildNowSlim: the full build's installer was pulled into the test-artifact folder above (instead
# of the whole ~14 GB tree), so install it and run the tests against the installed product — the
# same model as the official path. Available on every arch.
- ${{ if eq(parameters.buildSource, 'buildNowSlim') }}:
- pwsh: |-
& "$(build.sourcesdirectory)\.pipelines\installPowerToys.ps1" -InstallMode "Machine" -ArtifactPath "$(Pipeline.Workspace)\$(TestArtifactsName)"
displayName: Install PowerToys (Machine-Level)
- ${{ if ne(parameters.platform, 'arm64') }}:
- task: ScreenResolutionUtility@1
inputs:
displaySettings: 'optimal'
- script: |
dotnet test $(Build.SourcesDirectory)\src\modules\fancyzones\FancyZones.UITests\FancyZones.UITests.csproj --no-build -c $(BuildConfiguration) -p:Platform=$(BuildPlatform)
dotnet test $(Build.SourcesDirectory)\src\modules\fancyzones\FancyZonesEditor.UITests\FancyZonesEditor.UITests.csproj --no-build -c $(BuildConfiguration) -p:Platform=$(BuildPlatform)
# Start WinAppDriver once for the whole job — WinAppDriver's documented CI pattern
# (https://github.com/microsoft/WinAppDriver/blob/master/Docs/CI_AzureDevOps.md). Launching it
# detached gives it its own console whose stdin blocks, so it stays alive for the run instead of
# reading EOF and exiting the moment it starts listening (the failure mode when a test host launches
# it as a child). The legacy UITest harness reuses an already-listening instance rather than
# relaunching it per test, so this removes the per-assembly launch cost. The winappcli-based .Next
# tests don't use WinAppDriver. Best-effort: if the pre-start fails, each assembly still launches its own.
- pwsh: |
$winapp = "C:\Program Files (x86)\Windows Application Driver\WinAppDriver.exe"
if (Test-Path $winapp) {
Start-Process -FilePath $winapp
$deadline = (Get-Date).AddSeconds(30)
$ready = $false
while (-not $ready -and (Get-Date) -lt $deadline) {
try {
$client = [System.Net.Sockets.TcpClient]::new()
$client.Connect('127.0.0.1', 4723)
$ready = $client.Connected
$client.Close()
} catch {
Start-Sleep -Milliseconds 500
}
}
if ($ready) {
Write-Host 'WinAppDriver is listening on 127.0.0.1:4723.'
} else {
Write-Host "##vso[task.logissue type=warning]WinAppDriver did not start listening on :4723 within 30s; tests will launch it themselves."
}
} else {
Write-Host "##vso[task.logissue type=warning]WinAppDriver not found at $winapp; tests will launch it themselves."
}
displayName: Start WinAppDriver (shared, persistent)
- pwsh: |
$ErrorActionPreference = 'Stop'
$artifactRoot = "$(Pipeline.Workspace)\$(TestArtifactsName)"
if (-not (Test-Path $artifactRoot)) {
Write-Host "##vso[task.logissue type=error]UI test artifact not found: $artifactRoot"
exit 1
}
# uiTestModules is a template parameter; flatten it to a delimited string for the script.
$modulesRaw = '${{ join(';', parameters.uiTestModules) }}'
$modules = @()
if (-not [string]::IsNullOrWhiteSpace($modulesRaw)) {
$modules = $modulesRaw -split ';' | ForEach-Object { $_.Trim() } | Where-Object { $_ }
}
# Each UI test project is a Microsoft.Testing.Platform app; its entry assembly is paired
# with a *.runtimeconfig.json. Recurse under the staged 'tests' folders (tolerates TFM/RID subfolders).
$entries = Get-ChildItem -Path $artifactRoot -Filter '*.runtimeconfig.json' -File -Recurse -ErrorAction SilentlyContinue |
Where-Object { $_.Name -like '*UITests*' -and $_.FullName -match '\\tests\\' }
if ($modules.Count -gt 0) {
$entries = $entries | Where-Object { $n = $_.Name; ($modules | Where-Object { $n -like "*$_*" }).Count -gt 0 }
}
# Run each test assembly once (a project reference can copy a runner into a sibling's output).
$entries = $entries | Sort-Object FullName | Group-Object Name | ForEach-Object { $_.Group[0] }
if (-not $entries) {
Write-Host "##vso[task.logissue type=error]No UI test runners matched (modules: '$modulesRaw') under $artifactRoot"
exit 1
}
$resultsDir = "$(Common.TestResultsDirectory)"
New-Item -ItemType Directory -Path $resultsDir -Force | Out-Null
$failed = 0
foreach ($rc in ($entries | Sort-Object FullName -Unique)) {
$base = $rc.Name -replace '\.runtimeconfig\.json$', ''
$dir = $rc.DirectoryName
$exe = Join-Path $dir "$base.exe"
$dll = Join-Path $dir "$base.dll"
Write-Host "##[group]Run UI tests: $base"
Push-Location $dir
try {
if (Test-Path $exe) {
& $exe --report-trx --results-directory $resultsDir
} elseif (Test-Path $dll) {
& dotnet $dll --report-trx --results-directory $resultsDir
} else {
Write-Warning "No runner (exe/dll) found for $base in $dir"
}
if ($LASTEXITCODE -ne 0) {
Write-Warning "UI tests reported failures for $base (exit $LASTEXITCODE)"
$failed++
}
} finally {
Pop-Location
Write-Host "##[endgroup]"
}
}
if ($failed -gt 0) {
Write-Host "##vso[task.logissue type=error]$failed UI test project(s) reported failures."
exit 1
}
displayName: "Run UI Tests"
# Expose 'platform' as an environment variable so the harness's EnvironmentConfig.IsInPipeline
# is true and it captures failure media (screenshots / recording / logs). The legacy VSTest task
# set `env: { platform: $(TestPlatform) }`; the MTP migration to this pwsh step dropped it.
env:
platform: $(TestPlatform)
- task: PublishTestResults@2
displayName: "Publish UI Test Results"
condition: always()
inputs:
testResultsFormat: VSTest
testResultsFiles: '$(Common.TestResultsDirectory)/**/*.trx'
mergeTestResults: true
failTaskOnFailedTests: false
# Stop the shared WinAppDriver (paired with the start step above) so it doesn't linger on the
# self-hosted agent between jobs. Best-effort and always runs.
- pwsh: |
Get-Process -Name 'WinAppDriver' -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
displayName: Stop WinAppDriver
condition: always()

View File

@@ -26,6 +26,7 @@ parameters:
values:
- latestMainOfficialBuild
- buildNow
- buildNowSlim
- specificBuildId
- name: specificBuildId
type: string
@@ -37,18 +38,21 @@ parameters:
stages:
- ${{ each platform in parameters.buildPlatforms }}:
# Full build path: build PowerToys + UI tests + run tests
- ${{ if eq(parameters.buildSource, 'buildNow') }}:
# Full build path: build PowerToys + UI tests + run tests.
# buildNow downloads the whole build and runs in place; buildNowSlim downloads only the installer
# from that same full build and installs it. Both require the full build, so they share this path.
- ${{ if or(eq(parameters.buildSource, 'buildNow'), eq(parameters.buildSource, 'buildNowSlim')) }}:
- template: pipeline-ui-tests-full-build.yml
parameters:
platform: ${{ platform }}
buildSource: ${{ parameters.buildSource }}
enableMsBuildCaching: ${{ parameters.enableMsBuildCaching }}
useVSPreview: ${{ parameters.useVSPreview }}
useLatestWebView2: ${{ parameters.useLatestWebView2 }}
uiTestModules: ${{ parameters.uiTestModules }}
# Official build path: build UI tests only + download official build + run tests
- ${{ if ne(parameters.buildSource, 'buildNow') }}:
# Official build path: build UI tests only + download official build + run tests
- ${{ if and(ne(parameters.buildSource, 'buildNow'), ne(parameters.buildSource, 'buildNowSlim')) }}:
- template: pipeline-ui-tests-official-build.yml
parameters:
platform: ${{ platform }}

View File

@@ -2,6 +2,11 @@
parameters:
- name: platform
type: string
# buildNow = download the whole build artifact and run in place; buildNowSlim = download only the
# installer from this same full build and install it. Both build the full product in this template.
- name: buildSource
type: string
default: buildNow
- name: enableMsBuildCaching
type: boolean
default: false
@@ -53,7 +58,7 @@ stages:
platform: x64Win10
configuration: Release
useLatestWebView2: ${{ parameters.useLatestWebView2 }}
buildSource: 'buildNow'
buildSource: ${{ parameters.buildSource }}
uiTestModules: ${{ parameters.uiTestModules }}
- stage: Test_x64Win11_FullBuild
@@ -65,7 +70,7 @@ stages:
platform: x64Win11
configuration: Release
useLatestWebView2: ${{ parameters.useLatestWebView2 }}
buildSource: 'buildNow'
buildSource: ${{ parameters.buildSource }}
uiTestModules: ${{ parameters.uiTestModules }}
- ${{ if ne(parameters.platform, 'x64') }}:
@@ -78,5 +83,5 @@ stages:
platform: ${{ parameters.platform }}
configuration: Release
useLatestWebView2: ${{ parameters.useLatestWebView2 }}
buildSource: 'buildNow'
buildSource: ${{ parameters.buildSource }}
uiTestModules: ${{ parameters.uiTestModules }}

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 8 | See [Fuzzing Tests](doc/devdocs/tools/fuzzingtesting.md) |
| Fuzz Tests | OneFuzz, .NET 10 | See [Fuzzing Tests](doc/devdocs/tools/fuzzingtesting.md) |
### Test discipline

View File

@@ -66,7 +66,10 @@
<LanguageStandard>stdcpplatest</LanguageStandard>
<BuildStlModules>false</BuildStlModules>
<!-- TODO: _SILENCE_STDEXT_ARR_ITERS_DEPRECATION_WARNING for compatibility with VS 17.8. Check if we can remove. -->
<PreprocessorDefinitions>_SILENCE_STDEXT_ARR_ITERS_DEPRECATION_WARNING;_UNICODE;UNICODE;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<!-- TODO: _SILENCE_EXPERIMENTAL_COROUTINE_DEPRECATION_WARNINGS for VS 2026 (MSVC 14.51+). The STL turned
<experimental/coroutine> into a hard error (STL1011), and C++/WinRT's base.h still falls back to it when
__cpp_lib_coroutine isn't defined at include time. Remove once C++/WinRT no longer references the experimental header. -->
<PreprocessorDefinitions>_SILENCE_STDEXT_ARR_ITERS_DEPRECATION_WARNING;_SILENCE_EXPERIMENTAL_COROUTINE_DEPRECATION_WARNINGS;_UNICODE;UNICODE;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<!-- CLR + CFG are not compatible >:{ -->
<ControlFlowGuard Condition="'$(CLRSupport)' == ''">Guard</ControlFlowGuard>
<DebugInformationFormat Condition="'%(ControlFlowGuard)' == 'Guard'">ProgramDatabase</DebugInformationFormat>

View File

@@ -99,6 +99,7 @@
<PackageVersion Include="ReverseMarkdown" Version="4.1.0" />
<PackageVersion Include="RtfPipe" Version="2.0.7677.4303" />
<PackageVersion Include="ScipBe.Common.Office.OneNote" Version="3.0.1" />
<PackageVersion Include="ScreenRecorderLib" Version="6.6.0" />
<PackageVersion Include="SharpCompress" Version="0.37.2" />
<PackageVersion Include="Shmuelie.WinRTServer" Version="2.1.1" />
<!-- Don't update SkiaSharp.Views.WinUI to version 3.* branch as this brakes the HexBox control in Registry Preview. -->

View File

@@ -1589,6 +1589,7 @@ 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/Common.UI.csproj">
<Project Path="src/common/Common.UI.Controls/Common.UI.Controls.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="src/common/Common.UI.Controls/Common.UI.Controls.csproj">
<Project Path="src/common/Common.UI/Common.UI.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
@@ -54,10 +54,14 @@
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="src/common/UITestAutomation.Next/UITestAutomation.Next.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="src/common/UnitTests-CommonLib/UnitTests-CommonLib.vcxproj" Id="1a066c63-64b3-45f8-92fe-664e1cce8077" />
<Project Path="src/common/UnitTests-CommonUtils/UnitTests-CommonUtils.vcxproj" Id="8b5cfb38-ccba-40a8-ad7a-89c57b070884" />
<Project Path="src/common/updating/updating.vcxproj" Id="17da04df-e393-4397-9cf0-84dabe11032e" />
<Project Path="src/common/updating/UnitTests/UpdatingUnitTests.vcxproj" Id="a1b2c3d4-e5f6-7890-abcd-ef1234567890" />
<Project Path="src/common/updating/updating.vcxproj" Id="17da04df-e393-4397-9cf0-84dabe11032e" />
<Project Path="src/common/version/version.vcxproj" Id="cc6e41ac-8174-4e8a-8d22-85dd7f4851df" />
</Folder>
<Folder Name="/common/interop/">
@@ -190,6 +194,10 @@
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="src/modules/colorPicker/ColorPicker.UITests/ColorPicker.UITests.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
</Folder>
<Folder Name="/modules/CommandPalette/">
<Project Path="src/modules/cmdpal/CmdPalKeyboardService/CmdPalKeyboardService.vcxproj" Id="5f63c743-f6ce-4dba-a200-2b3f8a14e8c2" />
@@ -200,11 +208,11 @@
</Project>
</Folder>
<Folder Name="/modules/CommandPalette/Built-in Extensions/">
<Project Path="src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Microsoft.CmdPal.Ext.Apps.csproj">
<Project Path="src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Actions/Microsoft.CmdPal.Ext.Actions.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Actions/Microsoft.CmdPal.Ext.Actions.csproj">
<Project Path="src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Microsoft.CmdPal.Ext.Apps.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
@@ -718,11 +726,11 @@
</Project>
</Folder>
<Folder Name="/modules/PowerDisplay/">
<Project Path="src/modules/powerdisplay/PowerDisplay.Models/PowerDisplay.Models.csproj">
<Project Path="src/modules/powerdisplay/PowerDisplay.Lib/PowerDisplay.Lib.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="src/modules/powerdisplay/PowerDisplay.Lib/PowerDisplay.Lib.csproj">
<Project Path="src/modules/powerdisplay/PowerDisplay.Models/PowerDisplay.Models.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
@@ -755,6 +763,10 @@
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="src/modules/MeasureTool/Tests/ScreenRuler.UITests.Next/ScreenRuler.UITests.Next.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
</Folder>
<Folder Name="/modules/MouseWithoutBorders/">
<Project Path="src/modules/MouseWithoutBorders/App/Helper/MouseWithoutBordersHelper.csproj">
@@ -1095,6 +1107,10 @@
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="src/settings-ui/Settings.UITests/Settings.UITests.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
</Folder>
<Folder Name="/Solution Items/">
<File Path=".vsconfig" />
@@ -1126,14 +1142,14 @@
<BuildDependency Project="src/modules/fancyzones/editor/FancyZonesEditor/FancyZonesEditor.csproj" />
<BuildDependency Project="src/modules/fancyzones/FancyZonesLib/FancyZonesLib.vcxproj" />
<BuildDependency Project="src/modules/fancyzones/FancyZonesModuleInterface/FancyZonesModuleInterface.vcxproj" />
<BuildDependency Project="src/modules/GrabAndMove/GrabAndMove/GrabAndMove.vcxproj" />
<BuildDependency Project="src/modules/GrabAndMove/GrabAndMoveModuleInterface/GrabAndMoveModuleInterface.vcxproj" />
<BuildDependency Project="src/modules/imageresizer/dll/ImageResizerExt.vcxproj" />
<BuildDependency Project="src/modules/imageresizer/ui/ImageResizerUI.csproj" />
<BuildDependency Project="src/modules/keyboardmanager/dll/KeyboardManager.vcxproj" />
<BuildDependency Project="src/modules/launcher/Microsoft.Launcher/Microsoft.Launcher.vcxproj" />
<BuildDependency Project="src/modules/LightSwitch/LightSwitchModuleInterface/LightSwitchModuleInterface.vcxproj" />
<BuildDependency Project="src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj" />
<BuildDependency Project="src/modules/GrabAndMove/GrabAndMoveModuleInterface/GrabAndMoveModuleInterface.vcxproj" />
<BuildDependency Project="src/modules/GrabAndMove/GrabAndMove/GrabAndMove.vcxproj" />
<BuildDependency Project="src/modules/powerrename/dll/PowerRenameExt.vcxproj" />
<BuildDependency Project="src/modules/powerrename/lib/PowerRenameLib.vcxproj" />
<BuildDependency Project="src/modules/previewpane/Common/PreviewHandlerCommon.csproj" />

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 8 (Windows)` project
- Note: OneFuzz currently supports only .NET 8 projects. The Fuzz team is working on .NET 9 support.
1. Set up a `.NET 10 (Windows)` project
- Note: OneFuzz's .NET fuzzing is runtime-agnostic (".NET Core targets are preferred") and keys off the build drop directory, so PowerToys fuzz projects target net10 like the rest of the repo. Older guidance pinned .NET 8; that is no longer required.
2. Add the required files to your fuzzing test project:
- Create fuzzing test code
@@ -65,7 +65,7 @@ The `OneFuzzConfig.json` file provides critical information for deploying fuzzin
"targetName": "YourModule",
"jobDependencies": {
"binaries": [
"PowerToys\\x64\\Debug\\tests\\YourModule.FuzzTests\\net8.0-windows10.0.19041.0\\**"
"PowerToys\\x64\\Debug\\tests\\YourModule.FuzzTests\\net10.0-windows10.0.26100.0\\**"
]
}
}

View File

@@ -1,11 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Some items may be set in Directory.Build.props in root -->
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<!-- OneFuzz does not currently support testing with .NET 9.
As a temporary workaround, create a .NET 8 project and use file links
to include the code that needs testing. -->
<!-- Fuzz test projects pin their target framework here so it can be managed
independently of the main product TFM (Common.Dotnet.CsWinRT.props). This
was historically .NET 8 because OneFuzz did not support newer runtimes.
Per the current OneFuzz .NET fuzzing docs the service is runtime-agnostic
(".NET Core targets are preferred") and keys off the build drop directory,
so the fuzz projects now track net10 like the rest of the repo. -->
<PropertyGroup>
<TargetFramework>net8.0-windows10.0.26100.0</TargetFramework>
<TargetFramework>net10.0-windows10.0.26100.0</TargetFramework>
</PropertyGroup>
<!--

View File

@@ -0,0 +1,49 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.PowerToys.UITest.Next;
/// <summary>
/// Selector used to locate elements via winappcli. winappcli has its own selector grammar
/// (semantic slugs, plain text search) so this type maps onto the CLI's argument shape
/// rather than mimicking Selenium's <c>By</c>.
/// </summary>
public sealed class By
{
public enum Kind
{
/// <summary>Plain-text search against Name or AutomationId (case-insensitive substring).</summary>
Text,
/// <summary>Stable AutomationId, when the developer set one.</summary>
AutomationId,
/// <summary>A semantic slug (e.g., <c>btn-close-d1a0</c>) printed by <c>inspect</c>/<c>search</c>.</summary>
Slug,
}
public Kind Selector { get; }
public string Value { get; }
private By(Kind kind, string value)
{
Selector = kind;
Value = value;
}
/// <summary>Plain-text search; what you'd type into <c>winapp ui search "&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

@@ -0,0 +1,89 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using FormsClipboard = System.Windows.Forms.Clipboard;
namespace Microsoft.PowerToys.UITest.Next;
/// <summary>
/// Clipboard helpers that always execute on an STA thread (<see cref="FormsClipboard"/>
/// requires it). Tolerant — every method swallows clipboard errors and returns a default,
/// so callers can use them in test <c>finally</c> blocks without worrying about masking
/// the real failure.
/// </summary>
public static class ClipboardHelper
{
/// <summary>Return the current clipboard text, or <see cref="string.Empty"/> if none / on error.</summary>
public static string GetText() => RunSTA(() => FormsClipboard.ContainsText() ? FormsClipboard.GetText() : string.Empty) ?? string.Empty;
/// <summary>Clear the clipboard. Returns true on success, false on error.</summary>
public static bool Clear() => RunSTA(() => { FormsClipboard.Clear(); return true; });
/// <summary>Set the clipboard text. Returns true on success, false on error.</summary>
public static bool SetText(string value) => RunSTA(() => { FormsClipboard.SetText(value); return true; });
/// <summary>
/// Poll the clipboard up to <paramref name="timeoutMS"/> for the first non-empty text
/// different from <paramref name="ignoredValue"/>. Returns <see cref="string.Empty"/> on
/// timeout. Use when you've just cleared the clipboard and are waiting for an external
/// app (e.g. ColorPicker on click) to write into it.
/// </summary>
public static string WaitForText(string ignoredValue = "", int timeoutMS = 3_000, int pollIntervalMS = 100)
{
var deadline = DateTime.UtcNow + TimeSpan.FromMilliseconds(timeoutMS);
while (DateTime.UtcNow < deadline)
{
var text = GetText();
if (!string.IsNullOrEmpty(text) && text != ignoredValue)
{
return text;
}
Thread.Sleep(pollIntervalMS);
}
return string.Empty;
}
private static T? RunSTA<T>(Func<T> body, int maxAttempts = 10, int retryDelayMS = 100)
{
T? result = default;
try
{
var thread = new Thread(() =>
{
for (var attempt = 1; attempt <= maxAttempts; attempt++)
{
try
{
result = body();
return;
}
catch when (attempt < maxAttempts)
{
// The clipboard is a single shared resource: OpenClipboard fails transiently
// while another process still holds it open — very common right after an app
// writes data (e.g. the Measure Tool committing a measurement on click, which
// itself bails silently if OpenClipboard fails). A single-shot attempt surfaces
// that as a false empty/failure, so wait a beat and retry instead of giving up.
Console.WriteLine($"[clipboard] operation blocked (clipboard locked); retry {attempt}/{maxAttempts}");
Thread.Sleep(retryDelayMS);
}
catch
{
// Final attempt also failed — leave result at its default (null/false/empty).
}
}
});
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
thread.Join(TimeSpan.FromSeconds(5));
}
catch
{
}
return result;
}
}

View File

@@ -0,0 +1,131 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Runtime.InteropServices;
using System.Windows.Forms;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.PowerToys.UITest.Next;
/// <summary>
/// Display-mode helpers used only by the pipeline path of <see cref="UITestBase"/>: pin the primary
/// display to a known resolution so coordinate-sensitive tests are deterministic in CI, and dump the
/// monitor topology for post-mortem diagnostics. Native because winappcli exposes no display API.
/// </summary>
public static class DisplayHelper
{
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
private static extern int EnumDisplaySettings(string? lpszDeviceName, int iModeNum, ref DEVMODE lpDevMode);
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
private static extern int ChangeDisplaySettings(ref DEVMODE lpDevMode, int dwflags);
private const int ENUM_CURRENT_SETTINGS = -1;
private const int CDS_TEST = 0x00000002;
private const int CDS_UPDATEREGISTRY = 0x00000001;
private const int DISP_CHANGE_SUCCESSFUL = 0;
private const int DM_PELSWIDTH = 0x00080000;
private const int DM_PELSHEIGHT = 0x00100000;
/// <summary>
/// Pin the primary display to <paramref name="width"/> x <paramref name="height"/>. No-op when
/// already at that resolution. Best-effort — swallows failures because a CI agent may disallow
/// display-mode changes.
/// </summary>
/// <remarks>
/// Unlike the legacy harness (which left <c>dmFields</c> unset), this reads the current mode via
/// <c>EnumDisplaySettings(ENUM_CURRENT_SETTINGS)</c> and sets
/// <c>DM_PELSWIDTH | DM_PELSHEIGHT</c> — the documented, reliable way to request a resolution
/// change.
/// </remarks>
public static void NormalizeResolution(int width, int height)
{
try
{
var primary = Screen.PrimaryScreen;
if (primary is not null && primary.Bounds.Width == width && primary.Bounds.Height == height)
{
return;
}
var devMode = default(DEVMODE);
devMode.DmDeviceName = new string('\0', 32);
devMode.DmFormName = new string('\0', 32);
devMode.DmSize = (short)Marshal.SizeOf<DEVMODE>();
if (EnumDisplaySettings(null, ENUM_CURRENT_SETTINGS, ref devMode) == 0)
{
return;
}
devMode.DmPelsWidth = width;
devMode.DmPelsHeight = height;
devMode.DmFields = DM_PELSWIDTH | DM_PELSHEIGHT;
if (ChangeDisplaySettings(ref devMode, CDS_TEST) == DISP_CHANGE_SUCCESSFUL)
{
ChangeDisplaySettings(ref devMode, CDS_UPDATEREGISTRY);
}
}
catch
{
// Resolution normalization is a CI nicety, not a hard requirement.
}
}
/// <summary>Write the connected-monitor topology to the test log (and console) for diagnostics.</summary>
public static void LogMonitors(TestContext? testContext = null)
{
try
{
foreach (var m in MonitorInfo.GetAll())
{
var line = $"Monitor '{m.DeviceName}': {m.Width}x{m.Height} at ({m.Left},{m.Top}) primary={m.IsPrimary}";
testContext?.WriteLine(line);
Console.WriteLine(line);
}
}
catch
{
// Diagnostics only — never let logging fail a test.
}
}
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct DEVMODE
{
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)]
public string DmDeviceName;
public short DmSpecVersion;
public short DmDriverVersion;
public short DmSize;
public short DmDriverExtra;
public int DmFields;
public int DmPositionX;
public int DmPositionY;
public int DmDisplayOrientation;
public int DmDisplayFixedOutput;
public short DmColor;
public short DmDuplex;
public short DmYResolution;
public short DmTTOption;
public short DmCollate;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)]
public string DmFormName;
public short DmLogPixels;
public int DmBitsPerPel;
public int DmPelsWidth;
public int DmPelsHeight;
public int DmDisplayFlags;
public int DmDisplayFrequency;
public int DmICMMethod;
public int DmICMIntent;
public int DmMediaType;
public int DmDitherType;
public int DmReserved1;
public int DmReserved2;
public int DmPanningWidth;
public int DmPanningHeight;
}
}

View File

@@ -0,0 +1,13 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.PowerToys.UITest.Next;
public class Button : Element
{
public Button()
{
TargetControlType = "Button";
}
}

View File

@@ -0,0 +1,31 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.PowerToys.UITest.Next;
/// <summary>
/// WinUI/WPF <c>CheckBox</c> (UIA ControlType <c>CheckBox</c>). State is read via
/// <c>winapp ui get-property ToggleState</c> and changed via <c>winapp ui invoke</c>.
/// </summary>
public class CheckBox : Element
{
public CheckBox()
{
TargetControlType = "CheckBox";
}
/// <summary>True when UIA <c>ToggleState</c> is <c>On</c> (<c>Indeterminate</c> reads as not-checked).</summary>
public bool IsChecked => string.Equals(GetProperty("ToggleState"), "On", StringComparison.OrdinalIgnoreCase);
/// <summary>Flip to <paramref name="value"/> only if currently different.</summary>
public CheckBox SetCheck(bool value = true)
{
if (IsChecked != value)
{
Click();
}
return this;
}
}

View File

@@ -0,0 +1,52 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.PowerToys.UITest.Next;
/// <summary>
/// WinUI/WPF <c>ComboBox</c> (UIA ControlType <c>ComboBox</c>). Selection is driven CLI-first:
/// <see cref="Select"/> expands via <c>winapp ui invoke</c> then clicks the chosen item, while
/// editable combo boxes can be set directly with <see cref="SelectByText"/>
/// (<c>winapp ui set-value</c>).
/// </summary>
/// <remarks>
/// The dropdown items live in a popup that the owning process surfaces as a separate window
/// (e.g. Settings' <c>PopupHost</c>). Process-scoped sessions (<see cref="Session.FromProcess"/>)
/// see those items because every search re-resolves via <c>-a</c>; a window-scoped (<c>-w</c>)
/// session may not, in which case prefer <see cref="SelectByText"/>.
/// </remarks>
public class ComboBox : Element
{
public ComboBox()
{
TargetControlType = "ComboBox";
}
/// <summary>Currently selected item text via <c>winapp ui get-value</c> (SelectionPattern fallback).</summary>
public string SelectedText => GetValue();
/// <summary>
/// Expand the combo box (CLI <c>invoke</c> toggles ExpandCollapse) and click the item whose
/// Name matches <paramref name="itemName"/>.
/// </summary>
public ComboBox Select(string itemName, int timeoutMS = 5000)
{
EnsureBound();
Click();
Thread.Sleep(150);
Owner!.Find<Element>(By.Name(itemName), timeoutMS).Click();
return this;
}
/// <summary>
/// Set the combo box value directly via <c>winapp ui set-value</c> (UIA ValuePattern). Works
/// for editable combo boxes; for non-editable combos use <see cref="Select"/>.
/// </summary>
public ComboBox SelectByText(string text)
{
EnsureBound();
WinappCli.InvokeAssertSuccess("ui", "set-value", Selector, text, Owner!.TargetFlag, Owner!.TargetValue);
return this;
}
}

View File

@@ -0,0 +1,17 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.PowerToys.UITest.Next;
/// <summary>
/// Custom control (UIA ControlType <c>Custom</c>) — used by bespoke surfaces like FancyZones
/// zones and Workspaces canvases. Inherits drag from <see cref="Element"/>.
/// </summary>
public class Custom : Element
{
public Custom()
{
TargetControlType = "Custom";
}
}

View File

@@ -0,0 +1,390 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Globalization;
using System.Text.Json;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.PowerToys.UITest.Next;
/// <summary>Direction for <see cref="Element.Scroll"/> (maps to <c>winapp ui scroll --direction</c>).</summary>
public enum ScrollDirection
{
Up,
Down,
Left,
Right,
}
/// <summary>
/// Reference to a UI element resolved via winappcli. Wraps the resolved <see cref="Selector"/>
/// (slug or text query), the owning <see cref="Session"/>, and the metadata captured at lookup
/// time (control type, class name, name).
/// </summary>
/// <remarks>
/// Element instances are <i>stateless on the wire</i> — every property read and every action
/// shells out to <c>winapp ui …</c>. The cached <see cref="ControlType"/>, <see cref="ClassName"/>,
/// and <see cref="Name"/> are the values seen at <c>Find</c> time; for fresh values, re-find.
/// </remarks>
public class Element
{
internal Session? Owner { get; set; }
/// <summary>The selector winappcli will use to address this element (semantic slug, ID, or text query).</summary>
public string Selector { get; internal set; } = string.Empty;
/// <summary>Cached control type at lookup time (e.g. "Button", "ToggleSwitch").</summary>
public string ControlType { get; internal set; } = string.Empty;
/// <summary>Cached class name at lookup time (e.g. "ToggleSwitch", "TextBlock").</summary>
public string ClassName { get; internal set; } = string.Empty;
/// <summary>Cached Name property at lookup time.</summary>
public string Name { get; internal set; } = string.Empty;
/// <summary>Top-left X (screen pixels) reported by <c>search</c> at lookup time.</summary>
public int X { get; internal set; }
/// <summary>Top-left Y (screen pixels) reported by <c>search</c> at lookup time.</summary>
public int Y { get; internal set; }
/// <summary>Bounding-box width reported by <c>search</c> at lookup time.</summary>
public int Width { get; internal set; }
/// <summary>Bounding-box height reported by <c>search</c> at lookup time.</summary>
public int Height { get; internal set; }
/// <summary>UIA control type that this wrapper subclass expects (e.g. <c>"Button"</c>). Null = match anything.</summary>
protected string? TargetControlType { get; set; }
/// <summary>Optional ClassName filter applied alongside <see cref="TargetControlType"/>.</summary>
protected string? TargetClassName { get; set; }
internal bool MatchesFilter()
{
if (TargetControlType is not null &&
!string.Equals(ControlType, TargetControlType, StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (TargetClassName is not null &&
!string.Equals(ClassName, TargetClassName, StringComparison.OrdinalIgnoreCase))
{
return false;
}
return true;
}
/// <summary>
/// Activate the element. winappcli's <c>invoke</c> tries InvokePattern → TogglePattern →
/// SelectionItemPattern → ExpandCollapsePattern in order; <c>rightClick</c> falls back to
/// <c>click --right</c> via real mouse input.
/// </summary>
public virtual void Click(bool rightClick = false, int msPostAction = 200)
{
EnsureBound();
if (rightClick)
{
WinappCli.InvokeAssertSuccess("ui", "click", Selector, Owner!.TargetFlag, Owner!.TargetValue, "--right");
}
else
{
WinappCli.InvokeAssertSuccess("ui", "invoke", Selector, Owner!.TargetFlag, Owner!.TargetValue);
}
if (msPostAction > 0)
{
Thread.Sleep(msPostAction);
}
}
/// <summary>
/// Mouse-simulation left-click via <c>winapp ui click &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

@@ -0,0 +1,14 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.PowerToys.UITest.Next;
/// <summary>WinUI NavigationViewItem surfaces as ControlType.ListItem.</summary>
public class NavigationViewItem : Element
{
public NavigationViewItem()
{
TargetControlType = "ListItem";
}
}

View File

@@ -0,0 +1,14 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.PowerToys.UITest.Next;
/// <summary>WinUI/WPF <c>Pane</c> (UIA ControlType <c>Pane</c>). Inherits drag from <see cref="Element"/>.</summary>
public class Pane : Element
{
public Pane()
{
TargetControlType = "Pane";
}
}

View File

@@ -0,0 +1,31 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.PowerToys.UITest.Next;
/// <summary>
/// WinUI/WPF <c>RadioButton</c> (UIA ControlType <c>RadioButton</c>). Selected state is read via
/// <c>winapp ui get-property IsSelected</c>; selection is performed via <c>winapp ui invoke</c>.
/// </summary>
public class RadioButton : Element
{
public RadioButton()
{
TargetControlType = "RadioButton";
}
/// <summary>True when this radio button is the selected option (UIA SelectionItemPattern.IsSelected).</summary>
public bool IsSelected => Selected;
/// <summary>Select this radio button if it isn't already selected.</summary>
public RadioButton Select()
{
if (!IsSelected)
{
Click();
}
return this;
}
}

View File

@@ -0,0 +1,41 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Globalization;
namespace Microsoft.PowerToys.UITest.Next;
/// <summary>
/// WinUI/WPF <c>Slider</c> (UIA ControlType <c>Slider</c>). Reads and writes the value directly
/// through the CLI (<c>winapp ui get-value</c> / <c>set-value</c>, RangeValuePattern) — no
/// arrow-key stepping like the legacy harness.
/// </summary>
public class Slider : Element
{
public Slider()
{
TargetControlType = "Slider";
}
/// <summary>Current value via <c>winapp ui get-value</c>. Returns 0 when it can't be parsed.</summary>
public double Value
{
get
{
var raw = GetValue();
return double.TryParse(raw, NumberStyles.Any, CultureInfo.InvariantCulture, out var v) ? v : 0d;
}
}
/// <summary>Set the value directly via <c>winapp ui set-value</c> (RangeValuePattern).</summary>
public Slider SetValue(double value)
{
EnsureBound();
WinappCli.InvokeAssertSuccess(
"ui", "set-value", Selector,
value.ToString(CultureInfo.InvariantCulture),
Owner!.TargetFlag, Owner!.TargetValue);
return this;
}
}

View File

@@ -0,0 +1,17 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.PowerToys.UITest.Next;
/// <summary>
/// Tab control (UIA ControlType <c>Tab</c>). Inherits drag from <see cref="Element"/> for
/// tab-reorder / tear-off scenarios (see <see cref="Element.KeyDownAndDrag"/>).
/// </summary>
public class Tab : Element
{
public Tab()
{
TargetControlType = "Tab";
}
}

View File

@@ -0,0 +1,20 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.PowerToys.UITest.Next;
/// <summary>
/// Read-only text element (UIA ControlType <c>Text</c>, e.g. a WinUI <c>TextBlock</c>). The
/// rendered text is read via <c>winapp ui get-value</c>, which falls back to the UIA Name.
/// </summary>
public class TextBlock : Element
{
public TextBlock()
{
TargetControlType = "Text";
}
/// <summary>The displayed text via <c>winapp ui get-value</c>.</summary>
public string Text => GetValue();
}

View File

@@ -0,0 +1,46 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.PowerToys.UITest.Next;
/// <summary>Edit / TextBox control. Drives via <c>winapp ui set-value</c> and <c>get-value</c>.</summary>
public class TextBox : Element
{
public TextBox()
{
TargetControlType = "Edit";
}
/// <summary>Set the textbox content via winappcli's <c>set-value</c> (UIA ValuePattern).</summary>
public TextBox SetText(string value)
{
EnsureBound();
WinappCli.InvokeAssertSuccess("ui", "set-value", Selector, value, Owner!.TargetFlag, Owner!.TargetValue);
return this;
}
/// <summary>Current text content via <c>winapp ui get-value</c>.</summary>
public string Value
{
get
{
EnsureBound();
var r = WinappCli.Invoke("ui", "get-value", Selector, Owner!.TargetFlag, Owner!.TargetValue, "--json");
if (!r.Success)
{
return string.Empty;
}
try
{
using var doc = System.Text.Json.JsonDocument.Parse(r.StdOut);
return doc.RootElement.TryGetProperty("text", out var t) ? (t.GetString() ?? string.Empty) : string.Empty;
}
catch
{
return string.Empty;
}
}
}
}

View File

@@ -0,0 +1,17 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.PowerToys.UITest.Next;
/// <summary>
/// Resize/move <c>Thumb</c> (UIA ControlType <c>Thumb</c>), e.g. a splitter or slider handle.
/// Inherits drag from <see cref="Element"/>.
/// </summary>
public class Thumb : Element
{
public Thumb()
{
TargetControlType = "Thumb";
}
}

View File

@@ -0,0 +1,32 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.PowerToys.UITest.Next;
/// <summary>
/// WinUI <c>ToggleSwitch</c> surfaces as <c>ControlType.Button</c> + <c>ClassName="ToggleSwitch"</c>.
/// Pinning <see cref="Element.TargetClassName"/> avoids picking up sibling Buttons with the same Name
/// (e.g. the module's navigation card on the dashboard).
/// </summary>
public class ToggleSwitch : Button
{
public ToggleSwitch()
{
TargetClassName = "ToggleSwitch";
}
/// <summary>Reads UIA <c>ToggleState</c> via winappcli and compares to <c>"On"</c>.</summary>
public bool IsOn => string.Equals(GetProperty("ToggleState"), "On", StringComparison.OrdinalIgnoreCase);
/// <summary>Flip to <paramref name="value"/> only if currently different.</summary>
public ToggleSwitch Toggle(bool value = true)
{
if (IsOn != value)
{
Click();
}
return this;
}
}

View File

@@ -0,0 +1,13 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.PowerToys.UITest.Next;
public class Window : Element
{
public Window()
{
TargetControlType = "Window";
}
}

View File

@@ -0,0 +1,71 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Diagnostics;
using System.Runtime.InteropServices;
namespace Microsoft.PowerToys.UITest.Next;
/// <summary>
/// Win32 helpers to determine whether a process is running elevated (admin). winappcli exposes no
/// elevation query, so this stays native. Useful for tests that must branch on, or assert, the
/// runner's elevation state.
/// </summary>
public static class ElevationHelper
{
private const uint TOKEN_QUERY = 0x0008;
// TOKEN_INFORMATION_CLASS.TokenElevation
private const int TokenElevation = 20;
[DllImport("advapi32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool OpenProcessToken(IntPtr processHandle, uint desiredAccess, out IntPtr tokenHandle);
[DllImport("advapi32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GetTokenInformation(IntPtr tokenHandle, int tokenInformationClass, out uint tokenInformation, uint tokenInformationLength, out uint returnLength);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool CloseHandle(IntPtr hObject);
/// <summary>True when the current test-host process is elevated.</summary>
public static bool IsCurrentProcessElevated()
{
using var p = Process.GetCurrentProcess();
return IsHandleElevated(p.Handle);
}
/// <summary>True when process <paramref name="processId"/> is elevated; null if it can't be queried.</summary>
public static bool? IsProcessElevated(int processId)
{
try
{
using var p = Process.GetProcessById(processId);
return IsHandleElevated(p.Handle);
}
catch
{
return null;
}
}
private static bool IsHandleElevated(IntPtr processHandle)
{
if (!OpenProcessToken(processHandle, TOKEN_QUERY, out var token))
{
return false;
}
try
{
return GetTokenInformation(token, TokenElevation, out var elevated, sizeof(uint), out _) && elevated != 0;
}
finally
{
CloseHandle(token);
}
}
}

View File

@@ -0,0 +1,40 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.PowerToys.UITest.Next;
/// <summary>
/// Centralized access to the environment variables that influence UI-test execution. Mirrors the
/// legacy harness's <c>EnvironmentConfig</c> so module tests can branch on pipeline-vs-local and
/// installed-build-vs-dev-build the same way.
/// </summary>
public static class EnvironmentConfig
{
private static readonly Lazy<bool> InPipeline = new(() =>
!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("platform"))
// TF_BUILD is set to "True" on every Azure DevOps agent and can't be disabled — the
// canonical "running in a pipeline" signal. The test job exposes "platform" only as a
// template parameter (not an env var), so rely on TF_BUILD to enable CI diagnostics.
|| string.Equals(Environment.GetEnvironmentVariable("TF_BUILD"), "true", StringComparison.OrdinalIgnoreCase));
private static readonly Lazy<bool> UseInstaller = new(() =>
{
var raw = Environment.GetEnvironmentVariable("useInstallerForTest")
?? Environment.GetEnvironmentVariable("USEINSTALLERFORTEST");
return !string.IsNullOrEmpty(raw) && bool.TryParse(raw, out var b) && b;
});
private static readonly Lazy<string?> PlatformValue = new(() =>
Environment.GetEnvironmentVariable("platform"));
/// <summary>True when running in CI/CD (the <c>platform</c> env var is set).</summary>
public static bool IsInPipeline => InPipeline.Value;
/// <summary>True when tests should target the installed PowerToys build (<c>useInstallerForTest</c>).</summary>
public static bool UseInstallerForTest => UseInstaller.Value;
/// <summary>Build platform from the <c>platform</c> env var (e.g. <c>x64</c>, <c>arm64</c>), or null locally.</summary>
public static string? Platform => PlatformValue.Value;
}

View File

@@ -0,0 +1,162 @@
# UITestAutomation.Next — Parity & Hardening Plan
Tracks the gaps between the new winappcli-based framework (`UITestAutomation.Next`) and the
legacy WinAppDriver/Selenium framework (`UITestAutomation`), plus the ideal end state. **All gaps
below are now implemented** — see the per-gap **Done** notes and the Status summary. The detailed
sections are kept as the rationale/record.
> Reference points:
> - Legacy base: `src/common/UITestAutomation/UITestBase.cs`
> - New base: `src/common/UITestAutomation.Next/UITestBase.cs`
> - New launch: `src/common/UITestAutomation.Next/SessionHelper.cs`
## Status — implemented
| Gap | Status | Where |
|---|---|---|
| 1 — Clean-slate hygiene | ✅ Done | `UITestBase.PreTestHygiene()` + `virtual StaleProcessNames`; `WindowControl.TryKillProcessByName` |
| 2 — `WindowSize` wired in | ✅ Done | `UITestBase` ctor `size` param + `ApplyWindowSize()` |
| 3 — Module-enablement pre-config | ✅ Done | `UITestBase` ctor `enableModules` param → `ConfigureGlobalModuleSettings` before launch |
| 4 — Scope teardown / restart | ✅ Done | `SessionHelper.launchedByUs` / `StopIfStarted()` / `Restart()`; `UITestBase.RestartScope(...)` |
| 5 — Pipeline diagnostics | ✅ Done (pipeline-gated) | new `ScreenCapture.cs`, `ScreenRecording.cs`, `DisplayHelper.cs`; wired in `UITestBase` |
| 6 — Editor-scope launch audit | ✅ Documented | per-scope launch model in `ModuleConfigData.cs` (`PowerToysModule` doc) |
Framework/test-only change — no product code touched. Harness + both `.Next` consumers
(`ColorPicker.UITests`, `Settings.UITests`) build clean (exit 0).
## Current `.Next` init flow (baseline)
`TestInit` does exactly:
1. Probe `winapp.exe` availability (fail fast with install hint).
2. `new SessionHelper(scope)``Init()` → launch (runner `--open-settings` for Settings scope) and
wait for the first UIA window.
`TestCleanup` captures a single screenshot on failure, then a no-op `Session.Cleanup()`.
> Historical (pre-implementation) baseline. Everything below was present in the legacy harness but
> **missing or unwired** in `.Next` at the time of writing — now implemented (see Status above).
---
## Gap 1 — Clean-slate / window hygiene (HIGH, low risk)
Legacy `TestInit` starts every test from a known desktop state; `.Next` does none of it.
| Behavior | Legacy | `.Next` | Plumbing exists? |
|---|---|---|---|
| Minimize all windows (`Win+M`) | ✅ `KeyboardHelper.SendKeys(Key.Win, Key.M)` | ❌ | ✅ `SendKeys(Key.LWin, Key.M)` |
| Kill stale processes (`PowerToys`, `PowerToys.Settings`, `PowerToys.FancyZonesEditor`) | ✅ `CloseOtherApplications()` | ❌ | ✅ `WindowControl.TryKillProcess` |
| Dismiss popups (`{ESC}`) before launch | ✅ | ❌ | ✅ `KeyboardHelper` |
**Plan:** add a `PreTestHygiene()` step at the top of `TestInit` (before `SessionHelper.Init`):
minimize-all → ESC → kill known stale processes. Make the stale-process list a `virtual` property so
module suites can extend it.
**Done:** `UITestBase.PreTestHygiene()` runs at the top of `TestInit``Win+M``Esc` → kill each
name in the new `virtual StaleProcessNames` property. Uses the new `WindowControl.TryKillProcessByName`
(exact-name match) instead of the Contains-based `TryKillProcess`, so a `PowerToys.*.UITests` test
host is never caught by the "PowerToys" entry.
## Gap 2 — `WindowSize` not wired into the base (HIGH, low risk)
- Legacy ctor: `UITestBase(PowerToysModule scope, WindowSize size, string[]? commandLineArgs)` and applies
`size` during `Session` construction.
- `.Next` already has `WindowHelper.SetWindowSize`, the `WindowSize` enum, and `Session.Attach(size)`
but `UITestBase` has no `size` parameter and never applies one. Every `.Next` test runs at the window's
default size.
- Blocks porting tests that rely on a fixed size, e.g. `src/settings-ui/UITest-Settings/SettingsTests.cs`
(`WindowSize.Large`), Hosts/Workspaces (`WindowSize.Medium`), Peek (`Small_Vertical`).
**Plan:** add `WindowSize size = WindowSize.UnSpecified` to the `UITestBase` ctor; after `Init()` resolves
the window, call `WindowHelper.SetWindowSize(hwnd, size)` when `size != UnSpecified`.
**Done:** `UITestBase` ctor now takes `WindowSize size = UnSpecified` (defaulted). `ApplyWindowSize()`
runs after `Init()` (and after every `RestartScope`) and calls
`WindowHelper.SetWindowSize(new IntPtr(Session.WindowHandle), size)` when set.
## Gap 3 — Module-enablement pre-config not wired in (HIGH, low risk)
- Legacy `StartExe(enableModules)``SettingsConfigHelper.ConfigureGlobalModuleSettings(...)` seeds
`settings.json` **before** launch, so a test starts from a known module on/off state.
- `.Next` ships `SettingsConfigHelper.ConfigureGlobalModuleSettings` but **nothing calls it**. This is the
root of the "test assumes module is ON" fragility class.
**Plan:** add an optional `string[]? enableModules = null` ctor param. When non-null, call
`ConfigureGlobalModuleSettings(enableModules)` in `TestInit` **before** launching the runner. Document that
passing it gives a deterministic module baseline.
**Done:** `UITestBase` ctor takes `string[]? enableModules = null`; `TestInit` calls
`SettingsConfigHelper.ConfigureGlobalModuleSettings(enableModules)` before `SessionHelper.Init` when it's
non-null. The ctor value is also re-applied by `RestartScope()` (unless that call overrides it).
## Gap 4 — No scope teardown on cleanup (MEDIUM, needs design)
- Legacy `TestCleanup``sessionHelper.Cleanup()``ExitScopeExe()` stops what it launched.
- `.Next` `Session.Cleanup()` is a no-op and `EnsureRunning`'s "did I launch it" bool is discarded, so the
base never stops the process it started. (Individual tests like ColorPicker do their own `finally`.)
**Design call needed:** per-test teardown (kill scope process) vs. reuse a long-lived runner across a class.
Recommended: track the "launched-by-me" bool in `SessionHelper`, expose `StopIfStarted()`, and call it from
`TestCleanup` only when the base started the process. Add `RestartScope` convenience equivalent to legacy
`RestartScopeExe`.
**Done:** `SessionHelper` stores `launchedByUs` (set from `EnsureRunning`). `StopIfStarted()` tears down
**only** what we launched — kills the scope process and, for the Settings scope, the runner (exact-name
match); `TestCleanup` calls it. Instance `SessionHelper.Restart()` does kill → relaunch → rebind.
`UITestBase.RestartScope(string[]? enableModules = null)` re-seeds modules (ctor value if null), restarts,
reapplies window size, and returns the new `Session` — the `RestartScopeExe` equivalent.
## Gap 5 — Pipeline diagnostics (MEDIUM/LARGE, CI-only)
Legacy gates these on `EnvironmentConfig.IsInPipeline`:
| Behavior | Legacy | `.Next` | Notes |
|---|---|---|---|
| Normalize resolution to 1920×1080 | ✅ `ChangeDisplayResolution` | ❌ | Port to `MonitorInfo`/native helper |
| Monitor info snapshot | ✅ `GetMonitorInfo()` | ⚠️ `MonitorInfo` exists, not called in init | |
| Screenshot timer (1s cadence) | ✅ `ScreenCapture.TimerCallback` | ❌ | Needs port |
| Screen recording (FFmpeg) | ✅ `ScreenRecording` | ❌ | Needs port |
| On failure attach screenshots + recordings + **log files** | ✅ | ⚠️ single screenshot only | Add log-file + recording attach |
**Plan:** `.Next` `UITestBase` should branch on `EnvironmentConfig.IsInPipeline` and, when true, set up
screenshot timer + recording in `TestInit` and attach artifacts in `TestCleanup`. Treat FFmpeg recording as a
must have.
**Done (pipeline-gated on `EnvironmentConfig.IsInPipeline`):** new files `ScreenCapture.cs` (1s screenshot
timer), `ScreenRecording.cs` (FFmpeg encode), `DisplayHelper.cs` (`NormalizeResolution(1920,1080)` +
`LogMonitors`). `TestInit` normalizes resolution, logs the monitor topology, and starts the timer +
recording before launch; `TestCleanup` stops them and, on failure, attaches screenshots + recordings + the
PowerToys `*.log` files (`AddLogFilesToTestResults`), cleaning recordings on pass. The local (non-pipeline)
path still grabs the single winappcli `--capture-screen` failure shot. *Intentional difference:*
`NormalizeResolution` sets `DM_PELSWIDTH | DM_PELSHEIGHT` on the current mode (the documented, reliable
request) rather than the legacy's fields-unset call.
## Gap 6 — Editor scopes still launch the module exe directly (LOW, follow-up)
After the Settings-scope fix (`PowerToys.exe --open-settings`), editor scopes (Hosts, Workspaces,
CommandPalette, FancyZonesEditor, ScreenRuler) still launch their own exe in `SessionHelper.EnsureRunning`.
That is correct for editors that are meant to run standalone, but confirm each one against how the runner
launches it in production, and document the intended pattern per scope in `ModuleConfigData`.
**Done:** the launch model is now documented on the `PowerToysModule` enum in `ModuleConfigData.cs`
runner-owned Settings (`--open-settings`), the runner itself, standalone editor scopes (FancyZonesEditor,
Hosts, Workspaces, PowerRename, CommandPalette, ScreenRuler), and overlay/background modules (ColorPicker,
LightSwitch) that should be driven through the Settings scope rather than launched standalone.
---
## Suggested sequencing
1.**Phase 1 (quick wins, no API break risk to callers):** Gap 1 hygiene.
2.**Phase 2 (ctor surface):** Gaps 2 + 3 — add `WindowSize` and `enableModules` ctor params (defaulted, so
existing `.Next` tests keep compiling). Unblocks porting legacy Settings/Hosts/Workspaces tests.
3.**Phase 3 (lifecycle):** Gap 4 teardown/restart design + implementation.
4.**Phase 4 (CI):** Gap 5 diagnostics, FFmpeg recording.
5.**Phase 5 (cleanup):** Gap 6 per-scope launch audit + docs.
## Acceptance criteria (per phase)
- Existing `.Next` tests still compile and pass (defaulted params, no behavior change unless opted in).
- New behavior is opt-in or gated (e.g. pipeline-only) so local runs stay fast.
- Each ported behavior matches legacy semantics or documents the intentional difference.
- No product code changes — framework/test only.

View File

@@ -0,0 +1,204 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Runtime.InteropServices;
using FormsSendKeys = System.Windows.Forms.SendKeys;
namespace Microsoft.PowerToys.UITest.Next;
/// <summary>Virtual-key constants used by <see cref="KeyboardHelper"/>.</summary>
public enum Key : byte
{
Ctrl = 0x11,
Shift = 0x10,
Alt = 0x12,
LWin = 0x5B,
Tab = 0x09,
Esc = 0x1B,
Enter = 0x0D,
Space = 0x20,
Backspace = 0x08,
Delete = 0x2E,
Insert = 0x2D,
Home = 0x24,
End = 0x23,
PageUp = 0x21,
PageDown = 0x22,
Left = 0x25,
Up = 0x26,
Right = 0x27,
Down = 0x28,
A = 0x41,
B = 0x42,
C = 0x43,
D = 0x44,
E = 0x45,
F = 0x46,
G = 0x47,
H = 0x48,
I = 0x49,
J = 0x4A,
K = 0x4B,
L = 0x4C,
M = 0x4D,
N = 0x4E,
O = 0x4F,
P = 0x50,
Q = 0x51,
R = 0x52,
S = 0x53,
T = 0x54,
U = 0x55,
V = 0x56,
W = 0x57,
X = 0x58,
Y = 0x59,
Z = 0x5A,
Num0 = 0x30,
Num1 = 0x31,
Num2 = 0x32,
Num3 = 0x33,
Num4 = 0x34,
Num5 = 0x35,
Num6 = 0x36,
Num7 = 0x37,
Num8 = 0x38,
Num9 = 0x39,
F1 = 0x70,
F2 = 0x71,
F3 = 0x72,
F4 = 0x73,
F5 = 0x74,
F6 = 0x75,
F7 = 0x76,
F8 = 0x77,
F9 = 0x78,
F10 = 0x79,
F11 = 0x7A,
F12 = 0x7B,
}
/// <summary>
/// Global keyboard input. Uses the same hybrid strategy as the legacy harness because pure
/// <c>keybd_event</c> injection doesn't reliably trigger <c>RegisterHotKey</c>-registered global
/// hotkeys for the PowerToys runner: hold LWIN down via <c>keybd_event</c>, then send the
/// remaining chord via <see cref="System.Windows.Forms.SendKeys.SendWait"/> which uses
/// SendInput with proper modifier tracking, then release LWIN.
/// </summary>
public static class KeyboardHelper
{
[DllImport("user32.dll", SetLastError = true)]
#pragma warning disable SA1300 // win32 API name
private static extern void keybd_event(byte bVk, byte bScan, uint dwFlags, UIntPtr dwExtraInfo);
#pragma warning restore SA1300
private const uint KEYEVENTF_KEYUP = 0x2;
private const uint KEYEVENTF_EXTENDEDKEY = 0x1;
private const byte VK_LWIN = 0x5B;
/// <summary>
/// Send a chord of keys. If the chord contains <see cref="Key.LWin"/>, LWIN is held via
/// <c>keybd_event</c> while the remaining keys are sent via <see cref="FormsSendKeys.SendWait"/>.
/// Otherwise everything goes through SendKeys.SendWait (the modifier-aware Windows path).
/// </summary>
public static void SendKeys(params Key[] keys)
{
bool winDown = false;
var chord = new System.Text.StringBuilder();
foreach (var k in keys)
{
switch (k)
{
case Key.LWin:
keybd_event(VK_LWIN, 0, 0, UIntPtr.Zero);
winDown = true;
break;
case Key.Ctrl: chord.Append('^'); break;
case Key.Shift: chord.Append('+'); break;
case Key.Alt: chord.Append('%'); break;
case Key.Esc: chord.Append("{ESC}"); break;
case Key.Enter: chord.Append("{ENTER}"); break;
case Key.Tab: chord.Append("{TAB}"); break;
case Key.Space: chord.Append(' '); break;
case Key.Backspace: chord.Append("{BACKSPACE}"); break;
case Key.Delete: chord.Append("{DELETE}"); break;
case Key.Insert: chord.Append("{INSERT}"); break;
case Key.Home: chord.Append("{HOME}"); break;
case Key.End: chord.Append("{END}"); break;
case Key.PageUp: chord.Append("{PGUP}"); break;
case Key.PageDown: chord.Append("{PGDN}"); break;
case Key.Up: chord.Append("{UP}"); break;
case Key.Down: chord.Append("{DOWN}"); break;
case Key.Left: chord.Append("{LEFT}"); break;
case Key.Right: chord.Append("{RIGHT}"); break;
case Key.F1: chord.Append("{F1}"); break;
case Key.F2: chord.Append("{F2}"); break;
case Key.F3: chord.Append("{F3}"); break;
case Key.F4: chord.Append("{F4}"); break;
case Key.F5: chord.Append("{F5}"); break;
case Key.F6: chord.Append("{F6}"); break;
case Key.F7: chord.Append("{F7}"); break;
case Key.F8: chord.Append("{F8}"); break;
case Key.F9: chord.Append("{F9}"); break;
case Key.F10: chord.Append("{F10}"); break;
case Key.F11: chord.Append("{F11}"); break;
case Key.F12: chord.Append("{F12}"); break;
default:
// Letter / digit keys map to their lowercase character for SendKeys.
chord.Append(((char)k).ToString().ToLowerInvariant());
break;
}
}
try
{
if (chord.Length > 0)
{
FormsSendKeys.SendWait(chord.ToString());
}
}
finally
{
if (winDown)
{
keybd_event(VK_LWIN, 0, KEYEVENTF_KEYUP, UIntPtr.Zero);
}
}
}
/// <summary>Press (and hold) a key via <c>keybd_event</c>. Pair with <see cref="ReleaseKey"/>.</summary>
public static void PressKey(Key key) =>
keybd_event((byte)key, 0, IsExtended(key) ? KEYEVENTF_EXTENDEDKEY : 0u, UIntPtr.Zero);
/// <summary>Release a key previously pressed with <see cref="PressKey"/>.</summary>
public static void ReleaseKey(Key key) =>
keybd_event((byte)key, 0, KEYEVENTF_KEYUP | (IsExtended(key) ? KEYEVENTF_EXTENDEDKEY : 0u), UIntPtr.Zero);
/// <summary>Press + release a single key.</summary>
public static void SendKey(Key key)
{
PressKey(key);
Thread.Sleep(20);
ReleaseKey(key);
}
/// <summary>Press + release each key in order (independent taps, not a held chord).</summary>
public static void SendKeySequence(params Key[] keys)
{
foreach (var k in keys)
{
SendKey(k);
Thread.Sleep(20);
}
}
private static bool IsExtended(Key key) => key is
Key.Left or Key.Up or Key.Right or Key.Down or
Key.Home or Key.End or Key.PageUp or Key.PageDown or
Key.Insert or Key.Delete;
}

View File

@@ -0,0 +1,207 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.PowerToys.UITest.Next;
/// <summary>
/// Modules of PowerToys that a <see cref="UITestBase"/> can target.
/// </summary>
/// <remarks>
/// <para>
/// <b>Launch model per scope</b> (see <see cref="SessionHelper.EnsureRunning"/>):
/// </para>
/// <list type="bullet">
/// <item><description><see cref="PowerToysSettings"/> — runner-owned. Launched via
/// <c>PowerToys.exe --open-settings</c> so the runner owns module toggles and activation hotkeys.
/// This is the scope to use when a test drives a utility <i>through the Settings UI</i>
/// (e.g. <c>ColorPicker.UITests</c>), because a standalone module exe has no runner behind it.</description></item>
/// <item><description><see cref="Runner"/> — launches <c>PowerToys.exe</c> directly (the tray/runner host).</description></item>
/// <item><description><b>Editor scopes</b> (<see cref="FancyZonesEditor"/>, <see cref="Hosts"/>,
/// <see cref="Workspaces"/>, <see cref="PowerRename"/>, <see cref="CommandPalette"/>,
/// <see cref="ScreenRuler"/>) — launch their own exe standalone. These are designed to run as
/// self-contained editor windows, so binding directly to the editor's window is correct.</description></item>
/// <item><description><see cref="ColorPicker"/>, <see cref="LightSwitch"/> — overlay/background
/// modules that are <i>not</i> meant to be launched standalone by a test; drive them through the
/// <see cref="PowerToysSettings"/> scope (toggle + activation hotkey) instead. The entries exist
/// so window/process discovery can still resolve them once the runner spawns them.</description></item>
/// </list>
public enum PowerToysModule
{
PowerToysSettings,
Runner,
ColorPicker,
FancyZonesEditor,
Hosts,
Workspaces,
PowerRename,
CommandPalette,
ScreenRuler,
LightSwitch,
}
/// <summary>
/// Resolves executable paths, process names, and window titles for a <see cref="PowerToysModule"/>.
/// </summary>
/// <remarks>
/// Path resolution order: an explicit <c>POWERTOYS_INSTALL_DIR</c> override; then, when
/// <c>useInstallerForTest</c> is set, the installed build (Program Files / LocalAppData); otherwise
/// the build under test — located by walking up from the test assembly to the build-output root that
/// holds the exe (locally <c>&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

@@ -0,0 +1,104 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Runtime.InteropServices;
namespace Microsoft.PowerToys.UITest.Next;
/// <summary>
/// Multi-monitor enumeration via Win32 (<c>EnumDisplayMonitors</c> / <c>GetMonitorInfo</c>).
/// winappcli exposes no display topology, so this stays native — useful for multi-monitor
/// utilities (FancyZones, Mouse Utilities, Mouse Without Borders).
/// </summary>
public static class MonitorInfo
{
/// <summary>One physical display, in virtual-screen pixel coordinates.</summary>
public sealed record Monitor(
string DeviceName,
int Left,
int Top,
int Right,
int Bottom,
int WorkLeft,
int WorkTop,
int WorkRight,
int WorkBottom,
bool IsPrimary)
{
/// <summary>Full monitor width in pixels.</summary>
public int Width => Right - Left;
/// <summary>Full monitor height in pixels.</summary>
public int Height => Bottom - Top;
}
private const uint MONITORINFOF_PRIMARY = 0x1;
private delegate bool MonitorEnumProc(IntPtr hMonitor, IntPtr hdc, ref RECT lprcMonitor, IntPtr dwData);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool EnumDisplayMonitors(IntPtr hdc, IntPtr lprcClip, MonitorEnumProc lpfnEnum, IntPtr dwData);
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GetMonitorInfo(IntPtr hMonitor, ref MONITORINFOEX lpmi);
/// <summary>All connected displays, in enumeration order.</summary>
public static IReadOnlyList<Monitor> GetAll()
{
var list = new List<Monitor>();
EnumDisplayMonitors(IntPtr.Zero, IntPtr.Zero, EnumCallback, IntPtr.Zero);
return list;
bool EnumCallback(IntPtr hMonitor, IntPtr hdc, ref RECT lprcMonitor, IntPtr dwData)
{
var mi = new MONITORINFOEX { CbSize = Marshal.SizeOf<MONITORINFOEX>() };
if (GetMonitorInfo(hMonitor, ref mi))
{
list.Add(new Monitor(
mi.SzDevice,
mi.RcMonitor.Left,
mi.RcMonitor.Top,
mi.RcMonitor.Right,
mi.RcMonitor.Bottom,
mi.RcWork.Left,
mi.RcWork.Top,
mi.RcWork.Right,
mi.RcWork.Bottom,
(mi.DwFlags & MONITORINFOF_PRIMARY) != 0));
}
return true;
}
}
/// <summary>The primary display, or null if none reported.</summary>
public static Monitor? GetPrimary() => GetAll().FirstOrDefault(m => m.IsPrimary);
/// <summary>Number of connected displays.</summary>
public static int Count => GetAll().Count;
[StructLayout(LayoutKind.Sequential)]
private struct RECT
{
public int Left;
public int Top;
public int Right;
public int Bottom;
}
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct MONITORINFOEX
{
public int CbSize;
public RECT RcMonitor;
public RECT RcWork;
public uint DwFlags;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)]
public string SzDevice;
}
}

View File

@@ -0,0 +1,191 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.ComponentModel;
using System.Runtime.InteropServices;
namespace Microsoft.PowerToys.UITest.Next;
/// <summary>
/// Global mouse input via Win32 <c>SetCursorPos</c> and <c>SendInput</c>. Required for
/// scenarios like clicking inside the ColorPicker overlay, which is a transparent window that
/// can't be targeted via UIA / <c>winapp ui click</c>.
/// </summary>
public static class MouseHelper
{
[StructLayout(LayoutKind.Sequential)]
private struct POINT
{
public int X;
public int Y;
}
[StructLayout(LayoutKind.Sequential)]
private struct MOUSEINPUT
{
public int Dx;
public int Dy;
public uint MouseData;
public uint DwFlags;
public uint Time;
public UIntPtr DwExtraInfo;
}
[StructLayout(LayoutKind.Sequential)]
private struct INPUT
{
public uint Type;
public MOUSEINPUT Mi;
}
private const uint INPUT_MOUSE = 0;
private const uint MOUSEEVENTF_LEFTDOWN = 0x02;
private const uint MOUSEEVENTF_LEFTUP = 0x04;
private const uint MOUSEEVENTF_RIGHTDOWN = 0x08;
private const uint MOUSEEVENTF_RIGHTUP = 0x10;
private const uint MOUSEEVENTF_MIDDLEDOWN = 0x20;
private const uint MOUSEEVENTF_MIDDLEUP = 0x40;
private const uint MOUSEEVENTF_WHEEL = 0x0800;
private const int ClickDelayMs = 100;
private const int WheelTick = 120;
[DllImport("user32.dll")]
private static extern bool SetCursorPos(int x, int y);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GetCursorPos(out POINT lpPoint);
[DllImport("user32.dll", SetLastError = true)]
private static extern uint SendInput(uint nInputs, INPUT[] pInputs, int cbSize);
/// <summary>Move the OS cursor to absolute screen coordinates.</summary>
public static void MoveTo(int x, int y) => SetCursorPos(x, y);
/// <summary>Current cursor position in screen pixels.</summary>
public static (int X, int Y) GetMousePosition()
{
GetCursorPos(out var p);
return (p.X, p.Y);
}
/// <summary>Press the left mouse button down at the current position.</summary>
public static void LeftDown() => SendMouseInput(MOUSEEVENTF_LEFTDOWN);
/// <summary>Release the left mouse button.</summary>
public static void LeftUp() => SendMouseInput(MOUSEEVENTF_LEFTUP);
/// <summary>Press the right mouse button down at the current position.</summary>
public static void RightDown() => SendMouseInput(MOUSEEVENTF_RIGHTDOWN);
/// <summary>Release the right mouse button.</summary>
public static void RightUp() => SendMouseInput(MOUSEEVENTF_RIGHTUP);
/// <summary>Press the middle mouse button down at the current position.</summary>
public static void MiddleDown() => SendMouseInput(MOUSEEVENTF_MIDDLEDOWN);
/// <summary>Release the middle mouse button.</summary>
public static void MiddleUp() => SendMouseInput(MOUSEEVENTF_MIDDLEUP);
/// <summary>Press + release left mouse button at the current cursor position.</summary>
public static void LeftClick()
{
LeftDown();
Thread.Sleep(ClickDelayMs);
LeftUp();
}
/// <summary>Move cursor to (x,y) and left-click.</summary>
public static void LeftClickAt(int x, int y)
{
MoveTo(x, y);
Thread.Sleep(40);
LeftClick();
}
/// <summary>Press + release right mouse button at the current cursor position.</summary>
public static void RightClick()
{
RightDown();
Thread.Sleep(ClickDelayMs);
RightUp();
}
/// <summary>Press + release middle mouse button at the current cursor position.</summary>
public static void MiddleClick()
{
MiddleDown();
Thread.Sleep(ClickDelayMs);
MiddleUp();
}
/// <summary>Left double-click at the current cursor position.</summary>
public static void DoubleClick()
{
LeftClick();
Thread.Sleep(ClickDelayMs);
LeftClick();
}
/// <summary>Scroll the wheel by a raw amount (positive = up, negative = down; one tick = 120).</summary>
public static void ScrollWheel(int amount) => SendMouseInput(MOUSEEVENTF_WHEEL, amount);
/// <summary>Scroll the wheel up by one tick.</summary>
public static void ScrollUp() => ScrollWheel(WheelTick);
/// <summary>Scroll the wheel down by one tick.</summary>
public static void ScrollDown() => ScrollWheel(-WheelTick);
/// <summary>
/// Drag from one absolute screen point to another with real mouse input: move → left-down →
/// stepped move → left-up. winappcli has no drag verb, so this stays Win32. Coordinates are
/// physical screen pixels (matching <c>winapp ui search</c> bounds).
/// </summary>
public static void Drag(int fromX, int fromY, int toX, int toY)
{
MoveTo(fromX, fromY);
Thread.Sleep(100);
LeftDown();
Thread.Sleep(100);
MoveTo(toX, toY);
Thread.Sleep(200);
LeftUp();
}
/// <summary>
/// Injects a single mouse event into the system input queue via <see cref="SendInput"/>.
/// Button and wheel events fire at the current cursor position, so <paramref name="data"/>
/// only carries the wheel delta for <c>MOUSEEVENTF_WHEEL</c>.
/// </summary>
private static void SendMouseInput(uint flags, int data = 0)
{
var inputs = new INPUT[]
{
new INPUT
{
Type = INPUT_MOUSE,
Mi = new MOUSEINPUT
{
Dx = 0,
Dy = 0,
MouseData = (uint)data,
DwFlags = flags,
Time = 0,
DwExtraInfo = UIntPtr.Zero,
},
},
};
var sent = SendInput((uint)inputs.Length, inputs, Marshal.SizeOf<INPUT>());
if (sent != inputs.Length)
{
throw new Win32Exception(Marshal.GetLastWin32Error());
}
}
}

View File

@@ -0,0 +1,237 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Globalization;
using ScreenRecorderLib;
namespace Microsoft.PowerToys.UITest.Next;
/// <summary>
/// Records the desktop to an MP4 during a UI test using ScreenRecorderLib, which encodes in realtime
/// with native Microsoft Media Foundation (H.264). Used only by the pipeline path of
/// <see cref="UITestBase"/>. Unlike the old GDI + FFmpeg path there is nothing to probe for on PATH;
/// any runtime problem is surfaced through <c>OnRecordingFailed</c> and handled gracefully so the
/// failing test is never blocked — screenshots still cover the failure.
/// </summary>
internal sealed class ScreenRecording : IDisposable
{
// Deliberately light capture settings: on CI runners without a GPU, ScreenRecorderLib falls back
// to software H.264, and a full 1080p/30fps realtime encode competes with the test for CPU. 15 fps
// at 720p (~4x less pixel throughput than 1080p/30) is still plenty to see what a UI test did.
// Tune these down further (e.g. 10 fps / 960x540) if a runner is still CPU-starved.
private const int TargetFps = 15;
private const int OutputWidth = 1280;
private const int OutputHeight = 720;
/// <summary>Upper bound on how long to wait for Media Foundation to flush the MP4 after <c>Stop()</c>.</summary>
private static readonly TimeSpan FinalizeTimeout = TimeSpan.FromSeconds(30);
private readonly string outputDirectory;
private readonly string outputFilePath;
private readonly object syncRoot = new();
private Recorder? recorder;
private TaskCompletionSource<bool>? recordingFinished;
private bool isRecording;
public ScreenRecording(string outputDirectory)
{
this.outputDirectory = outputDirectory;
var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss", CultureInfo.InvariantCulture);
outputFilePath = Path.Combine(outputDirectory, $"recording_{timestamp}.mp4");
}
/// <summary>
/// True when recording can be attempted. ScreenRecorderLib ships its native encoder in-package,
/// so there is nothing to locate at runtime; a missing prerequisite (e.g. Media Foundation on a
/// Windows N/Server SKU) is reported through <c>OnRecordingFailed</c> rather than here.
/// </summary>
public bool IsAvailable => true;
/// <summary>Path the encoded MP4 will be written to.</summary>
public string OutputFilePath => outputFilePath;
/// <summary>Directory containing the recording output.</summary>
public string OutputDirectory => outputDirectory;
/// <summary>Start recording the main display. Best-effort and non-blocking.</summary>
public Task StartRecordingAsync()
{
lock (syncRoot)
{
if (isRecording)
{
return Task.CompletedTask;
}
try
{
Directory.CreateDirectory(outputDirectory);
var options = new RecorderOptions
{
OutputOptions = new OutputOptions
{
RecorderMode = RecorderMode.Video,
// Downscale from the test desktop (normalized to 1080p) to 720p. Both are 16:9 so
// Uniform is a clean scale with no letterboxing, and encoding ~2.25x fewer pixels
// is the single biggest CPU saving when the runner falls back to software H.264.
OutputFrameSize = new ScreenSize(OutputWidth, OutputHeight),
Stretch = StretchMode.Uniform,
},
VideoEncoderOptions = new VideoEncoderOptions
{
Framerate = TargetFps,
// Baseline is the cheapest H.264 profile to encode (no B-frames/CABAC); the
// library's own docs note lesser profiles "use less resources" — ideal for a
// throwaway diagnostic clip on a runner that falls back to software encoding.
Encoder = new H264VideoEncoder { EncoderProfile = H264Profile.Baseline },
// Force a constant frame rate. Without this, ScreenRecorderLib only sends a
// frame to the encoder when the screen *changes* (variable frame rate), while
// the MP4 still advertises TargetFps. Long static stretches (e.g. waiting for a
// module to launch) then collapse to a handful of frames and bursts of activity
// get packed together, so playback drifts out of sync with wall-clock time — the
// video runs fast/offset and the tail of the test looks cut off. Duplicating the
// previous frame keeps the timeline 1:1 with real time; H.264 compresses the
// repeated frames to almost nothing, so the file stays small. At 15 fps the extra
// duplicated idle frames are nearly free to encode.
IsFixedFramerate = true,
// Prefer encode speed over quality — this is a throwaway diagnostic clip, and a
// lower-latency encode leaves more CPU for the test itself on shared CI agents.
IsLowLatencyEnabled = true,
},
// UI tests don't need audio, and capturing it can fail on headless CI agents.
AudioOptions = new AudioOptions
{
IsAudioEnabled = false,
},
// Keep the cursor visible so a failed run shows what was being clicked.
MouseOptions = new MouseOptions
{
IsMousePointerEnabled = true,
},
};
recordingFinished = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
recorder = Recorder.CreateRecorder(options);
recorder.OnRecordingComplete += OnRecordingComplete;
recorder.OnRecordingFailed += OnRecordingFailed;
recorder.Record(outputFilePath);
isRecording = true;
Console.WriteLine($"Started screen recording at {TargetFps} FPS to {outputFilePath}");
}
catch (Exception ex)
{
Console.WriteLine($"Failed to start recording: {ex.Message}");
DisposeRecorder();
isRecording = false;
}
return Task.CompletedTask;
}
}
/// <summary>Stop recording and wait for Media Foundation to finalize the MP4. Best-effort.</summary>
public async Task StopRecordingAsync()
{
Recorder? activeRecorder;
TaskCompletionSource<bool>? finished;
lock (syncRoot)
{
if (!isRecording || recorder is null)
{
return;
}
activeRecorder = recorder;
finished = recordingFinished;
isRecording = false;
}
try
{
activeRecorder.Stop();
if (finished is not null)
{
// Bound the wait so a stuck encoder never hangs test teardown.
var completed = await Task.WhenAny(finished.Task, Task.Delay(FinalizeTimeout)).ConfigureAwait(false);
if (completed != finished.Task)
{
Console.WriteLine("Timed out waiting for the recording to finalize.");
}
}
if (File.Exists(outputFilePath))
{
var fileInfo = new FileInfo(outputFilePath);
Console.WriteLine($"Video created: {outputFilePath} ({fileInfo.Length / 1024.0 / 1024.0:F1} MB)");
}
}
catch (Exception ex)
{
Console.WriteLine($"Error stopping recording: {ex.Message}");
}
finally
{
DisposeRecorder();
}
}
public void Dispose()
{
if (isRecording)
{
StopRecordingAsync().GetAwaiter().GetResult();
}
DisposeRecorder();
GC.SuppressFinalize(this);
}
private void OnRecordingComplete(object? sender, RecordingCompleteEventArgs e)
{
recordingFinished?.TrySetResult(true);
}
private void OnRecordingFailed(object? sender, RecordingFailedEventArgs e)
{
Console.WriteLine($"Screen recording failed: {e.Error}");
recordingFinished?.TrySetResult(false);
}
private void DisposeRecorder()
{
lock (syncRoot)
{
if (recorder is null)
{
return;
}
recorder.OnRecordingComplete -= OnRecordingComplete;
recorder.OnRecordingFailed -= OnRecordingFailed;
try
{
recorder.Dispose();
}
catch (Exception ex)
{
Console.WriteLine($"Failed to dispose recorder: {ex.Message}");
}
recorder = null;
}
}
}

View File

@@ -0,0 +1,421 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.ObjectModel;
using System.Globalization;
using System.Text.Json;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.PowerToys.UITest.Next;
/// <summary>
/// A test session bound to either a specific window (HWND) or a whole process (name or PID).
/// All <see cref="Find{T}"/>/<see cref="FindAll{T}"/> calls route to <c>winapp ui search</c>
/// scoped by <see cref="TargetFlag"/>/<see cref="TargetValue"/>.
/// </summary>
/// <remarks>
/// Two scopes are supported:
/// <list type="bullet">
/// <item><description><c>Window</c> (<c>-w &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

@@ -0,0 +1,384 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Diagnostics;
using System.Text.Json;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.PowerToys.UITest.Next;
/// <summary>
/// Owns process launch + window resolution for a <see cref="PowerToysModule"/>. Equivalent to
/// the old <c>SessionHelper</c> but the engine is winappcli — no WinAppDriver, no Appium.
/// </summary>
/// <remarks>
/// <para>
/// Two consumption shapes:
/// <list type="bullet">
/// <item><description>Per-test (HWND-scoped): construct + call <see cref="Init"/>. <see cref="UITestBase"/>
/// does this in <c>[TestInitialize]</c>.</description></item>
/// <item><description>Class-scoped or process-scoped: the static helpers (<see cref="EnsureRunning"/>,
/// <see cref="IsRunning"/>, <see cref="GetProcessName"/>) let a smoke-test <c>[ClassInitialize]</c>
/// reuse the launch+wait flow without taking on a HWND binding.</description></item>
/// </list>
/// </para>
/// </remarks>
public sealed class SessionHelper
{
// Generous window-appearance budget. On a cold/busy CI agent the runner spends tens of seconds
// enabling every module and the Settings WinUI process cold-starts before its window appears.
// When the whole test job runs elevated (required so the legacy WinAppDriver harness can bind
// :4723) the runner's startup is slower still — ~100s to the first Settings window observed on a
// slow platform — so the budget is 150s. We wait patiently (and only re-issue the launch when
// nothing is alive) rather than kill-and-relaunch on a short deadline, which only resets a
// slow-but-healthy startup and never converges.
private static readonly TimeSpan LaunchTimeout = TimeSpan.FromSeconds(150);
private readonly PowerToysModule scope;
// True when this helper's Init/Restart actually launched the scope (vs. attaching to an
// already-running instance). StopIfStarted only tears down what we created.
private bool launchedByUs;
public SessionHelper(PowerToysModule scope)
{
this.scope = scope;
}
public Session Init()
{
launchedByUs = EnsureRunning(scope, LaunchTimeout);
return ResolveMainWindowOrFail();
}
/// <summary>
/// Force a clean restart of this helper's scope: kill the scope process (plus the runner for the
/// Settings scope), relaunch, and rebind to the fresh window. Marks the session launched-by-us so
/// <see cref="StopIfStarted"/> tears it down. Mirrors the net effect of the legacy <c>RestartScopeExe</c>.
/// </summary>
public Session Restart()
{
StopScope();
EnsureRunning(scope, LaunchTimeout);
launchedByUs = true;
return ResolveMainWindowOrFail();
}
/// <summary>
/// Stop the process(es) this helper launched. No-op when the target was already running at
/// <see cref="Init"/> time — we never kill state the test didn't create. Mirrors the legacy
/// <c>ExitScopeExe</c>, scoped to "only what we started".
/// </summary>
public void StopIfStarted()
{
if (!launchedByUs)
{
return;
}
StopScope();
launchedByUs = false;
}
private Session ResolveMainWindowOrFail()
{
var window = WaitForMainWindow(scope, LaunchTimeout);
Assert.IsNotNull(window, $"Main window for {scope} did not appear via winappcli within {LaunchTimeout.TotalSeconds:0}s");
return window!;
}
/// <summary>
/// Kill the scope's process and, for the Settings scope, the runner that owns it (the runner's
/// exit also stops the modules it spawned). Uses exact-name matching so unrelated processes that
/// merely contain "PowerToys" in their name (e.g. the test host) are left alone. Waits briefly
/// for the scope process to disappear.
/// </summary>
private void StopScope() => KillScopeProcessesAndWait(scope);
/// <summary>Process name as winappcli's <c>-a</c> flag (and <see cref="Process.GetProcessesByName(string)"/>) accept it.</summary>
public static string GetProcessName(PowerToysModule scope) => ModulePaths.ProcessNameFor(scope);
/// <summary>Returns <c>true</c> if at least one process matching <paramref name="scope"/> is running.</summary>
public static bool IsRunning(PowerToysModule scope) =>
Process.GetProcessesByName(GetProcessName(scope)).Length > 0;
/// <summary>
/// Ensure the runner-owned environment for <paramref name="scope"/> is up and has presented a
/// UIA-visible window. Returns <c>false</c> when the target was already running (nothing
/// launched), <c>true</c> when a launch was needed — callers track this so cleanup only kills
/// what the test itself started.
/// </summary>
/// <remarks>
/// <para>
/// The PowerToys <b>runner</b> (<c>PowerToys.exe</c>) is the single entry point. It installs the
/// centralized keyboard hook and owns every module's start/stop lifecycle. Tests therefore
/// launch the runner and drive modules through the Settings UI — they never launch a module's
/// UI exe (e.g. <c>PowerToys.ColorPickerUI.exe</c>) standalone. A standalone module process has
/// no runner behind it, so its activation hotkey never fires and toggling it in Settings does
/// nothing. For the <see cref="PowerToysModule.PowerToysSettings"/> scope we launch
/// <c>PowerToys.exe --open-settings</c>: the runner starts (or, being single-instance, the
/// already-running one is signalled) and presents the Settings window.
/// </para>
/// <para>
/// <c>UseShellExecute = true</c> is intentional: with <c>UseShellExecute = false</c> the
/// spawned process inherits this test-host's stdin/stdout/stderr handles, and the
/// Microsoft.Testing.Platform / MSTest runner won't declare the test run complete until
/// those pipes drain — which never happens until the target exits. Going through
/// ShellExecute gives the child its own console and detaches the handles.
/// </para>
/// <para>
/// PowerToys processes with single-instance gates (runner, Settings, ColorPicker) often hand
/// off to an existing instance and let the launcher PID exit with code 0 immediately. The
/// launcher PID is therefore intentionally discarded; readiness is judged purely by whether a
/// UIA window owned by the target process becomes visible.
/// </para>
/// </remarks>
public static bool EnsureRunning(PowerToysModule scope, TimeSpan timeout)
{
// Whether or not the scope process already exists, the test needs its WINDOW. EnsureWindow
// waits patiently and (idempotently) re-issues the launch as needed; it only kills/relaunches
// a genuinely-dead fresh launch, never a slow-but-healthy or class-shared (reused) window.
var alreadyRunning = IsRunning(scope);
EnsureWindow(scope, timeout, alreadyRunning);
return !alreadyRunning;
}
/// <summary>
/// Wait for a UIA-visible window from <paramref name="scope"/> to appear, launching / re-issuing
/// the launch as needed. The Settings scope is launched through the runner
/// (<c>PowerToys.exe --open-settings</c>); see <see cref="EnsureRunning"/> remarks.
/// </summary>
/// <remarks>
/// On a busy/cold CI agent the runner spends tens of seconds enabling every module before the
/// Settings window appears (~30-50s observed). A "kill + relaunch every 20s" loop kept resetting
/// that slow-but-healthy startup so it never converged (the "runner: 1, Settings: 2, no window"
/// failures). Instead this waits a single generous <paramref name="timeout"/> and only acts when
/// the window is still missing after a grace period: it re-issues the launch — idempotent, since
/// the runner is single-instance, so <c>--open-settings</c> just (re)shows Settings — and
/// additionally clears the single-instance mutex first only for a fresh launch that has gone
/// completely dead (nothing running), i.e. the handoff-to-a-now-exited-instance race. A
/// class-shared (reused) window is never killed.
/// </remarks>
private static void EnsureWindow(PowerToysModule scope, TimeSpan timeout, bool alreadyRunning)
{
var processName = GetProcessName(scope);
var runnerName = GetProcessName(PowerToysModule.Runner);
var nudgeInterval = TimeSpan.FromSeconds(25);
if (!alreadyRunning)
{
// Release the single-instance mutex any stale/half-launched instance still holds (pre-test
// hygiene kills without waiting), then launch.
KillScopeProcessesAndWait(scope);
LaunchScope(scope);
}
var deadline = DateTime.UtcNow + timeout;
var lastLaunch = DateTime.UtcNow;
while (DateTime.UtcNow < deadline)
{
if (WindowsFinder.ListByApp(processName).Count > 0)
{
// Give XAML a moment to populate the visual tree.
Thread.Sleep(750);
return;
}
if (DateTime.UtcNow - lastLaunch > nudgeInterval)
{
// Re-issue the launch ONLY when nothing is alive to present the window — the genuine
// "launcher handed off to an instance that then exited" race. If the runner is still
// alive it already owns the queued --open-settings request and, on a slow agent, may
// need tens of seconds to enable every module before it spawns Settings. Re-launching
// there is NOT free: each extra --open-settings queues another request that the runner
// honours with a SEPARATE Settings.exe (the "Settings: 3" pile-up seen in CI), and the
// competing single-instance processes plus the launch contention push the window past
// the deadline. So when anything is alive, keep waiting instead of piling on.
var alive = IsRunning(scope) || Process.GetProcessesByName(runnerName).Length > 0;
if (!alive)
{
if (!alreadyRunning)
{
KillScopeProcessesAndWait(scope);
}
LaunchScope(scope);
lastLaunch = DateTime.UtcNow;
}
}
Thread.Sleep(500);
}
Assert.Fail(
$"No UIA-visible window from process '{processName}' appeared within {timeout.TotalSeconds:0}s. " +
$"Live processes — runner '{runnerName}': {Process.GetProcessesByName(runnerName).Length}, " +
$"'{processName}': {Process.GetProcessesByName(processName).Length}.");
}
/// <summary>
/// Issue a single detached launch for <paramref name="scope"/>: the runner with
/// <c>--open-settings</c> for the Settings scope (the runner owns the Settings UI — see
/// <see cref="EnsureRunning"/> remarks), or the scope's own exe otherwise.
/// </summary>
private static void LaunchScope(PowerToysModule scope)
{
if (scope == PowerToysModule.PowerToysSettings)
{
LaunchViaShell(ModulePaths.ExePathFor(PowerToysModule.Runner), "--open-settings");
}
else
{
LaunchViaShell(ModulePaths.ExePathFor(scope), null);
}
}
/// <summary>
/// Kill the scope's process — plus the runner for the Settings scope, which owns the
/// single-instance mutex that <c>--open-settings</c> hands off to — and wait for them to exit.
/// The wait is the point: relaunching while a just-killed runner still holds its mutex hands the
/// new launch off to the dying instance, which never presents a window.
/// </summary>
private static void KillScopeProcessesAndWait(PowerToysModule scope)
{
var names = scope == PowerToysModule.PowerToysSettings
? new[] { GetProcessName(PowerToysModule.PowerToysSettings), GetProcessName(PowerToysModule.Runner) }
: new[] { GetProcessName(scope) };
foreach (var name in names)
{
WindowControl.TryKillProcessByName(name);
}
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
while (DateTime.UtcNow < deadline && names.Any(n => Process.GetProcessesByName(n).Length > 0))
{
Thread.Sleep(150);
}
}
/// <summary>
/// Launch <paramref name="exe"/> detached via ShellExecute (see <see cref="EnsureRunning"/>
/// remarks for why <c>UseShellExecute = true</c> is required). The launcher PID is discarded;
/// readiness is judged by window presence, not the process handle.
/// </summary>
private static void LaunchViaShell(string exe, string? arguments)
{
Assert.IsTrue(File.Exists(exe), $"Executable not found: {exe}");
try
{
using (Process.Start(new ProcessStartInfo
{
FileName = exe,
Arguments = arguments ?? string.Empty,
WorkingDirectory = Path.GetDirectoryName(exe)!,
UseShellExecute = true,
}) ?? throw new InvalidOperationException($"Process.Start returned null for {exe}"))
{
// Fire and forget — see EnsureRunning <remarks>.
}
}
catch (Exception ex)
{
Assert.Fail($"Failed to launch '{exe} {arguments}': {ex.Message}");
}
}
/// <summary>
/// Force a clean restart of the module: kill any running instance, wait for it to exit, then
/// launch a fresh one and wait for its window. Returns true once a window is visible.
/// </summary>
public static bool RestartScope(PowerToysModule scope, TimeSpan timeout)
{
var processName = GetProcessName(scope);
WindowControl.TryKillProcess(processName);
var killDeadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
while (DateTime.UtcNow < killDeadline && Process.GetProcessesByName(processName).Length > 0)
{
Thread.Sleep(150);
}
return EnsureRunning(scope, timeout);
}
/// <summary>
/// Poll <c>winapp ui list-windows --json</c> until a window matching the target module appears.
/// Returns a <see cref="Session"/> bound to its HWND.
/// </summary>
/// <remarks>
/// When the same process owns multiple windows (Settings exe also owns the <c>PopupHost</c>
/// overlay), we strictly prefer a window whose title contains the expected title. Process-name
/// match is only used as a fallback for modules that don't pin a specific title.
/// </remarks>
private static Session? WaitForMainWindow(PowerToysModule scope, TimeSpan timeout)
{
var processName = ModulePaths.ProcessNameFor(scope);
var expectedTitle = ModulePaths.MainWindowTitleFor(scope);
var deadline = DateTime.UtcNow + timeout;
while (DateTime.UtcNow < deadline)
{
var r = WinappCli.Invoke("ui", "list-windows", "--json");
if (r.Success && !string.IsNullOrEmpty(r.StdOut))
{
try
{
using var doc = JsonDocument.Parse(r.StdOut);
if (doc.RootElement.ValueKind == JsonValueKind.Array)
{
Session? processFallback = null;
foreach (var w in doc.RootElement.EnumerateArray())
{
var pn = w.TryGetProperty("processName", out var pnEl) ? (pnEl.GetString() ?? string.Empty) : string.Empty;
var title = w.TryGetProperty("title", out var tEl) ? (tEl.GetString() ?? string.Empty) : string.Empty;
var hwnd = w.TryGetProperty("hwnd", out var hwndEl) && hwndEl.ValueKind == JsonValueKind.Number ? hwndEl.GetInt64() : 0L;
var pid = w.TryGetProperty("processId", out var pidEl) && pidEl.ValueKind == JsonValueKind.Number ? pidEl.GetInt32() : 0;
if (hwnd == 0)
{
continue;
}
// Strict title match wins immediately — disambiguates from sibling
// windows owned by the same process (e.g. Settings + PopupHost).
if (!string.IsNullOrEmpty(expectedTitle) &&
title.Contains(expectedTitle, StringComparison.OrdinalIgnoreCase))
{
return new Session(scope, hwnd, title, pid, pn);
}
// Track the first process-name match as a fallback for modules where no
// expected title is configured.
if (processFallback is null &&
!string.IsNullOrEmpty(processName) &&
pn.Contains(processName, StringComparison.OrdinalIgnoreCase))
{
processFallback = new Session(scope, hwnd, title, pid, pn);
}
}
// No title match yet — only fall back to the process match if the module
// really has no expected title configured.
if (string.IsNullOrEmpty(expectedTitle) && processFallback is not null)
{
return processFallback;
}
}
}
catch
{
// Bad JSON during startup — keep polling.
}
}
Thread.Sleep(250);
}
return null;
}
}

View File

@@ -0,0 +1,138 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Text.Json;
using System.Text.Json.Nodes;
namespace Microsoft.PowerToys.UITest.Next;
/// <summary>
/// Lightweight helpers for preparing PowerToys settings JSON before a test launches a module.
/// Reads/writes the JSON files directly with System.Text.Json so the harness keeps zero product
/// dependencies — unlike the legacy helper, which referenced <c>Settings.UI.Library</c>.
/// </summary>
public static class SettingsConfigHelper
{
private static readonly JsonSerializerOptions Indented = new() { WriteIndented = true };
/// <summary>Root of the per-user PowerToys settings: <c>%LocalAppData%\Microsoft\PowerToys</c>.</summary>
public static string PowerToysSettingsRoot => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Microsoft",
"PowerToys");
private static string GlobalSettingsPath => Path.Combine(PowerToysSettingsRoot, "settings.json");
/// <summary>
/// Enable exactly the named modules in the global <c>settings.json</c> and disable every other
/// module already listed. Module names are the keys under <c>"enabled"</c> (e.g. "FancyZones",
/// "ColorPicker", "Peek"). Creates the file and keys when missing.
/// </summary>
public static void ConfigureGlobalModuleSettings(params string[]? modulesToEnable)
{
modulesToEnable ??= Array.Empty<string>();
Directory.CreateDirectory(PowerToysSettingsRoot);
var root = File.Exists(GlobalSettingsPath)
? (JsonNode.Parse(File.ReadAllText(GlobalSettingsPath)) as JsonObject) ?? new JsonObject()
: new JsonObject();
if (root["enabled"] is not JsonObject enabled)
{
enabled = new JsonObject();
root["enabled"] = enabled;
}
// Flip every already-listed module based on membership (disables the rest).
foreach (var key in enabled.Select(kv => kv.Key).ToList())
{
enabled[key] = modulesToEnable.Any(m => string.Equals(m, key, StringComparison.Ordinal));
}
// Ensure the requested modules are present and enabled even if not previously listed.
foreach (var module in modulesToEnable)
{
enabled[module] = true;
}
File.WriteAllText(GlobalSettingsPath, root.ToJsonString(Indented));
}
/// <summary>
/// Suppress the first-run "Welcome to PowerToys" (OOBE) and "What's new" (SCOOBE) windows. On a
/// fresh profile (e.g. a CI agent) the runner opens one of these centered, topmost windows, which
/// steals centre-screen mouse gestures (a coordinate measurement at screen-centre lands on the
/// Welcome window instead of the module overlay → empty result). Mirrors the runner's own gating:
/// marks OOBE as already opened (<c>oobe_settings.json</c> → <c>openedAtFirstLaunch=true</c>) and
/// disables the what's-new-after-updates setting (<c>settings.json</c> →
/// <c>show_whats_new_after_updates=false</c>, which the runner honours regardless of version).
/// Best-effort — never blocks a test from launching.
/// </summary>
public static void SuppressFirstRunExperience()
{
try
{
Directory.CreateDirectory(PowerToysSettingsRoot);
// OOBE: mark as already opened so the runner skips the Welcome window.
var oobe = new JsonObject { ["openedAtFirstLaunch"] = true };
File.WriteAllText(Path.Combine(PowerToysSettingsRoot, "oobe_settings.json"), oobe.ToJsonString(Indented));
// SCOOBE: disable "what's new after updates" (version-independent) in the general settings.
var root = File.Exists(GlobalSettingsPath)
? (JsonNode.Parse(File.ReadAllText(GlobalSettingsPath)) as JsonObject) ?? new JsonObject()
: new JsonObject();
root["show_whats_new_after_updates"] = false;
File.WriteAllText(GlobalSettingsPath, root.ToJsonString(Indented));
}
catch
{
// Best-effort — a fresh-run window is a nuisance, not a reason to fail the test setup.
}
}
/// <summary>
/// Update a module's <c>settings.json</c>
/// (<c>%LocalAppData%\Microsoft\PowerToys\&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

@@ -0,0 +1,56 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Look at Directory.Build.props in root for common stuff as well -->
<Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" />
<PropertyGroup>
<OutputType>Library</OutputType>
<TargetFramework>net10.0-windows10.0.26100.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<!--
WinForms is needed for System.Windows.Forms.SendKeys.SendWait, used by the global-hotkey
injection in KeyboardHelper. (Same approach as the legacy harness.)
-->
<UseWindowsForms>true</UseWindowsForms>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<RootNamespace>Microsoft.PowerToys.UITest.Next</RootNamespace>
<AssemblyName>Microsoft.PowerToys.UITest.Next</AssemblyName>
</PropertyGroup>
<ItemGroup>
<!--
Engine is winappcli (Microsoft.WinAppCli) — installed once per machine via
`winget install Microsoft.winappcli`. We shell out to winapp.exe and parse its
JSON output. No managed dependency on the engine — only MSTest's attribute surface.
-->
<PackageReference Include="MSTest.TestFramework" />
<!--
ScreenRecorderLib encodes the optional pipeline screen recording (ScreenRecording.cs)
in realtime via native Media Foundation. Pipeline-only diagnostic; no PATH/FFmpeg setup.
The package ships a single mixed-mode (managed + native) assembly per architecture under
build\<arch>\, wired up by a non-transitive build\*.targets. GeneratePathProperty lets us
re-publish that assembly as a copy-local item (below) so it also flows to the test projects
that reference this library.
-->
<PackageReference Include="ScreenRecorderLib" GeneratePathProperty="true" />
</ItemGroup>
<!--
Map the build platform to ScreenRecorderLib's per-architecture folder (Win32 -> x86; x64,
ARM64 and x86 match by name) and copy the resolved mixed-mode assembly to output as a
CopyToOutputDirectory item. Unlike the package's build\*.targets <Reference>, this flows
transitively through ProjectReference so consuming UI-test projects also deploy the DLL.
-->
<PropertyGroup>
<ScreenRecorderLibArch>$(Platform)</ScreenRecorderLibArch>
<ScreenRecorderLibArch Condition="'$(Platform)' == 'Win32'">x86</ScreenRecorderLibArch>
</PropertyGroup>
<ItemGroup Condition="Exists('$(PkgScreenRecorderLib)\build\$(ScreenRecorderLibArch)\ScreenRecorderLib.dll')">
<None Include="$(PkgScreenRecorderLib)\build\$(ScreenRecorderLibArch)\ScreenRecorderLib.dll">
<Link>ScreenRecorderLib.dll</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<Visible>false</Visible>
</None>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,476 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.PowerToys.UITest.Next;
/// <summary>
/// Base class for the next-generation PowerToys UI tests. Engine is winappcli — every UI call
/// shells out to <c>winapp.exe</c>. No WinAppDriver, no Selenium, no third-party NuGet packages.
/// </summary>
/// <remarks>
/// <para>
/// Drop-in shape replacement for the existing <c>Microsoft.PowerToys.UITest.UITestBase</c>:
/// inherit, pass a <see cref="PowerToysModule"/>, and use <c>Session</c> / <c>Find&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

@@ -0,0 +1,401 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.ComponentModel;
using System.Diagnostics;
using System.Globalization;
using System.Text;
using System.Text.Json;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.PowerToys.UITest.Next;
/// <summary>
/// Thin wrapper around the winappcli executable. Every public method shells out to
/// <c>winapp.exe</c>, captures stdout/stderr/exit-code, and (where requested) parses the
/// <c>--json</c> envelope using <see cref="JsonDocument"/>.
/// </summary>
/// <remarks>
/// <para>
/// Engine prerequisites: install once with <c>winget install Microsoft.winappcli</c>. The CLI
/// lands on PATH at <c>%LOCALAPPDATA%\Microsoft\WindowsApps\winapp.exe</c>.
/// </para>
/// <para>
/// All invocations set <c>WINAPP_CLI_TELEMETRY_OPTOUT=1</c> and disable update checks via
/// <c>WINAPP_CLI_UPDATE_CHECK=0</c> so the CLI never injects extra lines into stdout.
/// </para>
/// </remarks>
public static class WinappCli
{
/// <summary>Stable hint surfaced when the CLI is missing or fails — used in all error paths.</summary>
public const string InstallHint =
"winapp.exe not found. Install once with: winget install Microsoft.winappcli " +
"(or set the WINAPP_CLI_PATH environment variable to its full path).";
private static readonly Lazy<string> ExecutablePath = new(ResolveExecutable);
/// <summary>
/// Per-invocation guard. A hung <c>winapp.exe</c> call must fail fast and name the offending
/// command instead of blocking until the suite's outer timeout fires (which buries the cause).
/// Commands that pass a longer <c>-t</c> wait extend this; see <see cref="ResolveInvokeTimeout"/>.
/// </summary>
private static readonly TimeSpan DefaultInvokeTimeout = TimeSpan.FromSeconds(60);
/// <summary>
/// Serializes winapp.exe invocations. Two CLI UIA clients querying the same target at once can hang
/// each other (worst against the live Measure Tool overlay), and the stray-process guard in
/// <see cref="Invoke"/> must never race a legitimate in-flight call — so invocations run one at a time.
/// </summary>
private static readonly object InvokeGate = new();
public sealed record Result(int ExitCode, string StdOut, string StdErr, IReadOnlyList<string> Args)
{
public bool Success => ExitCode == 0;
/// <summary>
/// One-line, assertion-friendly description of a failed invocation. Format:
/// <c>"winapp ui invoke X -w 12345 -> exit 1; stderr: not found"</c>. Falls back to
/// stdout if stderr is empty.
/// </summary>
public string DescribeFailure()
{
var sb = new StringBuilder();
sb.Append("winapp ");
sb.AppendJoin(' ', Args);
sb.Append(" -> exit ").Append(ExitCode);
if (!string.IsNullOrWhiteSpace(StdErr))
{
sb.Append("; stderr: ").Append(StdErr.Trim());
}
else if (!string.IsNullOrWhiteSpace(StdOut))
{
sb.Append("; stdout: ").Append(StdOut.Trim());
}
return sb.ToString();
}
public JsonDocument ParseJson()
{
try
{
return JsonDocument.Parse(StdOut);
}
catch (JsonException ex)
{
throw new InvalidOperationException(
$"winappcli stdout was not valid JSON. {DescribeFailure()}",
ex);
}
}
}
/// <summary>
/// Returns true when <c>winapp.exe</c> resolves to a real file AND responds to
/// <c>--version</c>. Use from <c>[ClassInitialize]</c> / <c>[AssemblyInitialize]</c> /
/// <see cref="UITestBase"/> to fail the entire suite once with a clear install hint,
/// instead of letting every test produce its own opaque process-launch failure.
/// </summary>
public static bool IsAvailable()
{
if (!TryResolveExecutable(out _))
{
return false;
}
try
{
return Invoke("--version").Success;
}
catch
{
return false;
}
}
/// <summary>Run <c>winapp.exe</c> with the given arguments. Returns exit code and captured streams.</summary>
public static Result Invoke(params string[] args)
{
// Serialize invocations so two winapp.exe never run at once — and so the stray-process guard in
// InvokeLocked can't race a legitimate in-flight call.
lock (InvokeGate)
{
return InvokeLocked(args);
}
}
private static Result InvokeLocked(string[] args)
{
// Before spinning up a new winapp.exe, kill any stray one left behind by a previous
// timed-out/killed call: a second UIA client against the same target (e.g. the live Measure
// Tool overlay) can wedge the new call. Serialized, so anything alive here is a leftover.
KillStrayWinappProcesses(args);
var psi = new ProcessStartInfo
{
FileName = ExecutablePath.Value,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8,
};
// Suppress telemetry banner and update-check notice so --json output stays clean.
psi.Environment["WINAPP_CLI_TELEMETRY_OPTOUT"] = "1";
psi.Environment["WINAPP_CLI_UPDATE_CHECK"] = "0";
foreach (var a in args)
{
psi.ArgumentList.Add(a);
}
var overall = Stopwatch.StartNew();
using var p = StartWinappProcess(psi);
var stdoutTask = p.StandardOutput.ReadToEndAsync();
var stderrTask = p.StandardError.ReadToEndAsync();
var timeout = ResolveInvokeTimeout(args);
if (!p.WaitForExit((int)timeout.TotalMilliseconds))
{
try
{
Console.WriteLine($"[winappcli] killing hung call after {timeout.TotalSeconds:0}s: winapp {string.Join(' ', args)}");
p.Kill(entireProcessTree: true);
p.WaitForExit(5000); // make sure the tree is actually gone before returning
}
catch
{
// Raced with a natural exit between the wait timing out and the kill — nothing to do.
}
throw new TimeoutException(
$"winapp {string.Join(' ', args)} did not exit within {timeout.TotalSeconds:0}s and was killed.");
}
// winapp.exe itself has now exited; capture how long that took.
var processMs = overall.ElapsedMilliseconds;
// Bound the wait for the async stdout/stderr readers. The output is already buffered, but a
// child that inherited the redirected pipe keeps the handle open so the readers (and a
// parameterless WaitForExit) never see EOF. After a short grace, clear strays — invocations are
// serialized, so any winapp.exe alive now is that child — which closes the pipe so the reads
// finish with the full, already-captured output.
if (!Task.WhenAll(stdoutTask, stderrTask).Wait(TimeSpan.FromSeconds(2)))
{
Console.WriteLine(
"[winappcli] output stalled after winapp.exe exit (lingering child held the pipe); clearing strays for: " +
$"winapp {string.Join(' ', args)}");
KillStrayWinappProcesses(args);
Task.WhenAll(stdoutTask, stderrTask).Wait(TimeSpan.FromSeconds(3));
}
// Surface where slow calls actually spend their time — winapp.exe runtime vs waiting for the
// output streams to drain after it exited — so a slow timestamp can be attributed correctly.
var totalMs = overall.ElapsedMilliseconds;
if (totalMs > 2000)
{
Console.WriteLine(
$"[winappcli] slow call {totalMs}ms (winapp.exe ran {processMs}ms, output drain {totalMs - processMs}ms): " +
$"winapp {string.Join(' ', args)}");
}
return new Result(
p.ExitCode,
stdoutTask.IsCompletedSuccessfully ? stdoutTask.Result : string.Empty,
stderrTask.IsCompletedSuccessfully ? stderrTask.Result : string.Empty,
args);
}
/// <summary>
/// Kill any <c>winapp.exe</c> still running before a new invocation (or when output stalls). A stray
/// instance is a leftover from a previous call that timed out / didn't fully exit; a second UIA
/// client against the same target can wedge a call, so we clear them and log each kill. Best-effort
/// and bounded — never throws.
/// </summary>
private static void KillStrayWinappProcesses(string[] args)
{
Process[] strays;
try
{
strays = Process.GetProcessesByName("winapp");
}
catch
{
return;
}
foreach (var stray in strays)
{
try
{
Console.WriteLine(
$"[winappcli] found a stray winapp.exe (pid {stray.Id}) still running; killing it before: " +
$"winapp {string.Join(' ', args)}");
stray.Kill(entireProcessTree: true);
stray.WaitForExit(5000);
}
catch
{
// The stray may have exited on its own between enumeration and kill — fine.
}
finally
{
stray.Dispose();
}
}
}
/// <summary>
/// Process-guard budget for one invocation. Defaults to <see cref="DefaultInvokeTimeout"/>; when the
/// command carries its own <c>-t</c>/<c>--timeout</c> wait in milliseconds (e.g. <c>wait-for</c>), the
/// guard is extended past that wait plus a grace margin so a legitimate long wait isn't killed early.
/// </summary>
private static TimeSpan ResolveInvokeTimeout(string[] args)
{
var budget = DefaultInvokeTimeout;
for (var i = 0; i < args.Length - 1; i++)
{
if ((string.Equals(args[i], "-t", StringComparison.Ordinal) ||
string.Equals(args[i], "--timeout", StringComparison.Ordinal)) &&
int.TryParse(args[i + 1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var ms) &&
ms > 0)
{
var withGrace = TimeSpan.FromMilliseconds(ms) + TimeSpan.FromSeconds(30);
if (withGrace > budget)
{
budget = withGrace;
}
}
}
return budget;
}
/// <summary>Run and throw if the exit code is non-zero. Use for fire-and-forget commands.</summary>
public static Result InvokeAssertSuccess(params string[] args)
{
var r = Invoke(args);
Assert.AreEqual(0, r.ExitCode, r.DescribeFailure());
return r;
}
/// <summary>Run a <c>--json</c> command and return the parsed root <see cref="JsonElement"/>.</summary>
public static JsonElement InvokeJson(params string[] args)
{
var r = Invoke(args);
if (!r.Success)
{
// Many --json commands (search, wait-for) return exit 1 with a valid envelope on
// "no match" / "timed out". Still parse so the caller can branch on envelope fields.
try
{
using var doc = JsonDocument.Parse(r.StdOut);
return doc.RootElement.Clone();
}
catch
{
Assert.Fail($"{r.DescribeFailure()} (stdout was not JSON)");
return default;
}
}
using var ok = JsonDocument.Parse(r.StdOut);
return ok.RootElement.Clone();
}
/// <summary>
/// Locate <c>winapp.exe</c> without throwing or asserting. <see cref="IsAvailable"/> uses
/// this to probe quietly; the lazy <see cref="ResolveExecutable"/> wraps it for the
/// first real call.
/// </summary>
public static bool TryResolveExecutable(out string path)
{
// 1) Explicit override (CI / dev convenience).
var env = Environment.GetEnvironmentVariable("WINAPP_CLI_PATH");
if (!string.IsNullOrEmpty(env) && File.Exists(env))
{
path = env;
return true;
}
// 2) Standard winget install location.
var winget = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Microsoft",
"WindowsApps",
"winapp.exe");
if (File.Exists(winget))
{
path = winget;
return true;
}
// 3) Anything on PATH.
var pathEnv = Environment.GetEnvironmentVariable("PATH") ?? string.Empty;
foreach (var dir in pathEnv.Split(Path.PathSeparator))
{
if (string.IsNullOrWhiteSpace(dir))
{
continue;
}
try
{
var candidate = Path.Combine(dir, "winapp.exe");
if (File.Exists(candidate))
{
path = candidate;
return true;
}
}
catch
{
}
}
path = string.Empty;
return false;
}
/// <summary>
/// Start <c>winapp.exe</c>, retrying the transient launch failure that affects Windows App
/// Execution Aliases. The <c>winapp.exe</c> found on PATH is the reparse-point stub under
/// <c>%LOCALAPPDATA%\Microsoft\WindowsApps</c>; launching an alias through <c>CreateProcess</c>
/// (<c>UseShellExecute = false</c>) intermittently throws <see cref="Win32Exception"/> with
/// <c>ERROR_INVALID_PARAMETER</c> (87, "The parameter is incorrect") before the alias resolves.
/// The launch is atomic — nothing ran — so retrying with a short backoff is safe and
/// idempotent. Other Win32 errors (missing file, access denied) propagate immediately so a
/// genuine misconfiguration still fails fast.
/// </summary>
private static Process StartWinappProcess(ProcessStartInfo psi)
{
const int maxAttempts = 4;
for (int attempt = 1; ; attempt++)
{
try
{
return Process.Start(psi) ?? throw new InvalidOperationException(
$"Failed to start winapp.exe ({psi.FileName}). {InstallHint}");
}
catch (Win32Exception ex) when (ex.NativeErrorCode == 87 && attempt < maxAttempts)
{
// App Execution Alias not resolved yet — back off briefly and retry.
Thread.Sleep(100 * attempt);
}
}
}
private static string ResolveExecutable()
{
if (TryResolveExecutable(out var path))
{
return path;
}
throw new InvalidOperationException(InstallHint);
}
}

View File

@@ -0,0 +1,342 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Diagnostics;
using System.Runtime.InteropServices;
namespace Microsoft.PowerToys.UITest.Next;
/// <summary>
/// Fault-tolerant window cleanup helpers. Every method swallows exceptions and returns a
/// boolean — they're designed for test <c>finally</c> blocks where a cleanup failure must
/// never mask the real test failure.
/// </summary>
/// <remarks>
/// winappcli has no <c>close</c> verb, so closing goes through Win32 <c>WM_CLOSE</c>
/// (graceful) with an optional process-kill fallback. Focus uses <c>SetForegroundWindow</c>
/// against the HWND that <see cref="WindowsFinder"/> already discovers.
/// </remarks>
public static class WindowControl
{
[DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool PostMessageW(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam);
[DllImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool SetForegroundWindow(IntPtr hWnd);
[DllImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool IsWindow(IntPtr hWnd);
[DllImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
[DllImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam);
[DllImport("user32.dll", SetLastError = true)]
private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
[DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern int GetClassNameW(IntPtr hWnd, [Out] char[] lpClassName, int nMaxCount);
[DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern int GetWindowTextW(IntPtr hWnd, [Out] char[] lpString, int nMaxCount);
private delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
private const uint WM_CLOSE = 0x0010;
private const int SW_RESTORE = 9;
/// <summary>
/// A top-level window discovered by <see cref="EnumerateProcessWindows"/>: its native handle,
/// window class name, and title.
/// </summary>
public readonly record struct ProcessWindow(IntPtr Hwnd, string ClassName, string Title);
/// <summary>
/// Enumerate the top-level windows owned by any process in <paramref name="processIds"/> using the
/// pure Win32 <c>EnumWindows</c> API. Unlike winappcli's UI-Automation-backed <c>list-windows</c>,
/// this never attaches a UIA client or walks a window's UIA tree, so it is safe to call against a
/// process that is mid screen-capture (e.g. the Measure Tool overlay) without disturbing it.
/// </summary>
public static IReadOnlyList<ProcessWindow> EnumerateProcessWindows(IReadOnlyCollection<int> processIds)
{
var result = new List<ProcessWindow>();
if (processIds.Count == 0)
{
return result;
}
try
{
EnumWindows(
(hWnd, _) =>
{
try
{
GetWindowThreadProcessId(hWnd, out var pid);
if (processIds.Contains((int)pid))
{
result.Add(new ProcessWindow(hWnd, GetWindowClassName(hWnd), GetWindowTitle(hWnd)));
}
}
catch
{
// Ignore any single window we can't read; keep enumerating.
}
return true;
},
IntPtr.Zero);
}
catch
{
// Best-effort: return whatever was collected before the failure.
}
return result;
}
private static string GetWindowClassName(IntPtr hWnd)
{
var buffer = new char[256];
var len = GetClassNameW(hWnd, buffer, buffer.Length);
return len > 0 ? new string(buffer, 0, len) : string.Empty;
}
private static string GetWindowTitle(IntPtr hWnd)
{
var buffer = new char[512];
var len = GetWindowTextW(hWnd, buffer, buffer.Length);
return len > 0 ? new string(buffer, 0, len) : string.Empty;
}
/// <summary>
/// Send <c>WM_CLOSE</c> to every window owned by <paramref name="appNameOrPid"/> and wait
/// up to <paramref name="timeoutMS"/> for them to disappear. Tolerant: returns false on
/// any failure instead of throwing.
/// </summary>
public static bool TryCloseByApp(string appNameOrPid, int timeoutMS = 5_000)
{
try
{
var windows = WindowsFinder.ListByApp(appNameOrPid);
if (windows.Count == 0)
{
return true; // nothing to close
}
foreach (var w in windows)
{
TryCloseHwnd(w.Hwnd);
}
var deadline = DateTime.UtcNow + TimeSpan.FromMilliseconds(timeoutMS);
while (DateTime.UtcNow < deadline)
{
if (WindowsFinder.ListByApp(appNameOrPid).Count == 0)
{
return true;
}
Thread.Sleep(150);
}
return false;
}
catch
{
return false;
}
}
/// <summary>
/// Send <c>WM_CLOSE</c> to every window matching <paramref name="predicate"/> on the
/// process and wait for them to disappear. Use when one process owns several windows and
/// only some should be closed (e.g. close the ColorPicker editor but leave the overlay).
/// </summary>
public static bool TryCloseByApp(string appNameOrPid, Func<WindowsFinder.WindowInfo, bool> predicate, int timeoutMS = 5_000)
{
try
{
var targets = WindowsFinder.ListByApp(appNameOrPid).Where(predicate).ToList();
if (targets.Count == 0)
{
return true;
}
foreach (var w in targets)
{
TryCloseHwnd(w.Hwnd);
}
var deadline = DateTime.UtcNow + TimeSpan.FromMilliseconds(timeoutMS);
while (DateTime.UtcNow < deadline)
{
if (!WindowsFinder.ListByApp(appNameOrPid).Any(predicate))
{
return true;
}
Thread.Sleep(150);
}
return false;
}
catch
{
return false;
}
}
/// <summary>
/// Bring the first window owned by <paramref name="appNameOrPid"/> to the foreground.
/// If the window is minimized it's first restored. Tolerant.
/// </summary>
public static bool TryFocusByApp(string appNameOrPid)
{
try
{
var w = WindowsFinder.ListByApp(appNameOrPid).FirstOrDefault();
if (w is null || w.Hwnd == 0)
{
return false;
}
var hwnd = new IntPtr(w.Hwnd);
if (!IsWindow(hwnd))
{
return false;
}
ShowWindow(hwnd, SW_RESTORE);
return SetForegroundWindow(hwnd);
}
catch
{
return false;
}
}
/// <summary>
/// Cleanup convenience: close every window of <paramref name="closeApp"/> (if any) and
/// bring <paramref name="focusApp"/> to the foreground. Mirrors the pattern in the legacy
/// <c>TestHelper.CleanupTest</c> (close target window → re-attach to Settings) but does
/// not throw, so it's safe to call from a test <c>finally</c>.
/// </summary>
public static void SafeCloseAndFocus(string closeApp, string focusApp, int closeTimeoutMS = 5_000)
{
TryCloseByApp(closeApp, closeTimeoutMS);
TryFocusByApp(focusApp);
}
/// <summary>
/// Force-terminate every process whose name contains <paramref name="processNameContains"/>.
/// Use only as a last resort when <see cref="TryCloseByApp(string, int)"/> failed and the
/// module's window must be gone before the next test starts.
/// </summary>
public static bool TryKillProcess(string processNameContains)
{
try
{
var hits = Process.GetProcesses()
.Where(p =>
{
try
{
return p.ProcessName.Contains(processNameContains, StringComparison.OrdinalIgnoreCase);
}
catch
{
return false;
}
})
.ToList();
foreach (var p in hits)
{
try
{
p.Kill(entireProcessTree: true);
}
catch
{
// Best effort.
}
finally
{
p.Dispose();
}
}
return hits.Count > 0;
}
catch
{
return false;
}
}
/// <summary>
/// Force-terminate every process whose name <b>exactly</b> equals <paramref name="exactProcessName"/>
/// (no extension, case-insensitive — the form <see cref="Process.GetProcessesByName(string)"/> accepts).
/// Prefer this over <see cref="TryKillProcess"/> for short names like "PowerToys" that are a
/// substring of unrelated processes (e.g. a "PowerToys.*.UITests" test host the run is executing
/// in). Tolerant — returns false on any failure instead of throwing.
/// </summary>
public static bool TryKillProcessByName(string exactProcessName)
{
try
{
var hits = Process.GetProcessesByName(exactProcessName);
foreach (var p in hits)
{
try
{
p.Kill(entireProcessTree: true);
}
catch
{
// Best effort.
}
finally
{
p.Dispose();
}
}
return hits.Length > 0;
}
catch
{
return false;
}
}
private static void TryCloseHwnd(long hwnd)
{
try
{
if (hwnd == 0)
{
return;
}
var handle = new IntPtr(hwnd);
if (IsWindow(handle))
{
PostMessageW(handle, WM_CLOSE, IntPtr.Zero, IntPtr.Zero);
}
}
catch
{
// Best effort.
}
}
}

View File

@@ -0,0 +1,189 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Drawing;
using System.Runtime.InteropServices;
namespace Microsoft.PowerToys.UITest.Next;
/// <summary>Preset window sizes for <see cref="WindowHelper.SetWindowSize(IntPtr, WindowSize)"/>.</summary>
public enum WindowSize
{
/// <summary>No size change.</summary>
UnSpecified,
/// <summary>640 x 480.</summary>
Small,
/// <summary>480 x 640.</summary>
Small_Vertical,
/// <summary>1024 x 768.</summary>
Medium,
/// <summary>768 x 1024.</summary>
Medium_Vertical,
/// <summary>1920 x 1080.</summary>
Large,
/// <summary>1080 x 1920.</summary>
Large_Vertical,
}
/// <summary>
/// Win32 window + screen helpers for scenarios winappcli can't express: resizing/positioning a
/// window, reading a screen pixel color, and querying display geometry. Window discovery itself
/// stays CLI-first (<see cref="WindowsFinder"/>; <see cref="IsWindowOpen"/>).
/// </summary>
public static class WindowHelper
{
[StructLayout(LayoutKind.Sequential)]
private struct RECT
{
public int Left;
public int Top;
public int Right;
public int Bottom;
}
private const uint SWP_NOMOVE = 0x0002;
private const uint SWP_NOZORDER = 0x0004;
private const uint SWP_NOACTIVATE = 0x0010;
private const int SM_CXSCREEN = 0;
private const int SM_CYSCREEN = 1;
private const int SW_MAXIMIZE = 3;
[DllImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect);
[DllImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int x, int y, int cx, int cy, uint uFlags);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
[DllImport("user32.dll")]
private static extern int GetSystemMetrics(int nIndex);
[DllImport("user32.dll")]
private static extern IntPtr GetDC(IntPtr hWnd);
[DllImport("user32.dll")]
private static extern int ReleaseDC(IntPtr hWnd, IntPtr hDC);
[DllImport("gdi32.dll")]
private static extern uint GetPixel(IntPtr hdc, int x, int y);
/// <summary>True when any UIA-visible window's title contains <paramref name="titleContains"/> (CLI-based).</summary>
public static bool IsWindowOpen(string titleContains) =>
WindowsFinder.ListAll().Any(w => w.Title.Contains(titleContains, StringComparison.OrdinalIgnoreCase));
/// <summary>
/// Resize a window to a preset <see cref="WindowSize"/> and CENTER it on the primary display.
/// The preset is first clamped to ~90% of the display, so a fixed size (e.g. Large = 1920x1080)
/// can't spill off the edges of an equally-sized (1920x1080) display once positioned at a
/// non-origin top-left — the cause of the "shifted right and bottom, partially off-screen"
/// Settings window. On a larger display the preset size is used as-is, just centered.
/// </summary>
public static void SetWindowSize(IntPtr hWnd, WindowSize size)
{
var (w, h) = Dimensions(size);
if (w <= 0 || h <= 0)
{
return;
}
var (screenW, screenH) = GetDisplaySize();
// Clamp to ~90% of the screen so there's always a visible margin on every edge.
int cw = screenW > 0 ? Math.Min(w, (int)(screenW * 0.9)) : w;
int ch = screenH > 0 ? Math.Min(h, (int)(screenH * 0.9)) : h;
// Center on the primary display (never negative, so the title bar stays reachable).
int x = Math.Max(0, (screenW - cw) / 2);
int y = Math.Max(0, (screenH - ch) / 2);
SetWindowPos(hWnd, IntPtr.Zero, x, y, cw, ch, SWP_NOZORDER | SWP_NOACTIVATE);
}
/// <summary>Resize a window to explicit width/height, keeping its current position (no move).</summary>
public static void SetMainWindowSize(IntPtr hWnd, int width, int height) =>
SetWindowPos(hWnd, IntPtr.Zero, 0, 0, width, height, SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE);
/// <summary>
/// Maximize a window so it fills the monitor work area and is fully on-screen. Used as the default
/// window state for tests so a module's restored (possibly small or off-screen) last window rect
/// can't hide controls such as the Settings NavigationView pane.
/// </summary>
public static void MaximizeWindow(IntPtr hWnd) => ShowWindow(hWnd, SW_MAXIMIZE);
/// <summary>(Left, Top, Right, Bottom) of the window in screen pixels.</summary>
public static (int Left, int Top, int Right, int Bottom) GetWindowBounds(IntPtr hWnd)
{
if (GetWindowRect(hWnd, out var r))
{
return (r.Left, r.Top, r.Right, r.Bottom);
}
return (0, 0, 0, 0);
}
/// <summary>Center point of the window in screen pixels.</summary>
public static (int CenterX, int CenterY) GetWindowCenter(IntPtr hWnd)
{
var (l, t, rgt, b) = GetWindowBounds(hWnd);
return (l + ((rgt - l) / 2), t + ((b - t) / 2));
}
/// <summary>Primary display size in pixels.</summary>
public static (int Width, int Height) GetDisplaySize() =>
(GetSystemMetrics(SM_CXSCREEN), GetSystemMetrics(SM_CYSCREEN));
/// <summary>Center of the primary display in pixels.</summary>
public static (int CenterX, int CenterY) GetScreenCenter()
{
var (w, h) = GetDisplaySize();
return (w / 2, h / 2);
}
/// <summary>Color of the on-screen pixel at (<paramref name="x"/>, <paramref name="y"/>) via GDI.</summary>
public static Color GetPixelColor(int x, int y)
{
var hdc = GetDC(IntPtr.Zero);
try
{
var pixel = GetPixel(hdc, x, y);
int r = (int)(pixel & 0x000000FF);
int g = (int)((pixel & 0x0000FF00) >> 8);
int b = (int)((pixel & 0x00FF0000) >> 16);
return Color.FromArgb(r, g, b);
}
finally
{
ReleaseDC(IntPtr.Zero, hdc);
}
}
/// <summary>On-screen pixel color at (<paramref name="x"/>, <paramref name="y"/>) as <c>#RRGGBB</c>.</summary>
public static string GetPixelColorHex(int x, int y)
{
var c = GetPixelColor(x, y);
return $"#{c.R:X2}{c.G:X2}{c.B:X2}";
}
private static (int Width, int Height) Dimensions(WindowSize size) => size switch
{
WindowSize.Small => (640, 480),
WindowSize.Small_Vertical => (480, 640),
WindowSize.Medium => (1024, 768),
WindowSize.Medium_Vertical => (768, 1024),
WindowSize.Large => (1920, 1080),
WindowSize.Large_Vertical => (1080, 1920),
_ => (0, 0),
};
}

View File

@@ -0,0 +1,155 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Diagnostics;
using System.Text.Json;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.PowerToys.UITest.Next;
/// <summary>
/// Static helpers for discovering and attaching to windows that aren't the test's primary scope.
/// </summary>
/// <remarks>
/// Most tests target one module's main window (handled by <see cref="UITestBase"/> + <see cref="SessionHelper"/>).
/// But scenarios like "send the ColorPicker hotkey and assert the Editor pops up" need to discover
/// a brand-new window that may not exist when the test starts. These helpers wrap
/// <c>winapp ui list-windows --json</c> to find/wait for those windows by process or title.
/// </remarks>
public static class WindowsFinder
{
public sealed record WindowInfo(long Hwnd, string Title, string ProcessName, int ProcessId, string ClassName, int Width, int Height);
/// <summary>List all UIA-visible windows.</summary>
/// <remarks>
/// NOTE: winappcli's unfiltered <c>list-windows --json</c> currently omits windows that have
/// no Win32 title (e.g. the ColorPicker editor exposes its name only via UIA Name, not the
/// HWND title). Use <see cref="ListByApp"/> with a process/PID filter when you need to see
/// those — winappcli returns them in the filtered form.
/// </remarks>
public static IReadOnlyList<WindowInfo> ListAll() => Parse(WinappCli.Invoke("ui", "list-windows", "--json"));
/// <summary>
/// List UIA-visible windows belonging to <paramref name="appNameOrPid"/> (process name substring or PID).
/// Uses winappcli's <c>-a</c> filter, which works around the bug where unfiltered
/// <c>list-windows</c> drops windows without a Win32 title.
/// </summary>
public static IReadOnlyList<WindowInfo> ListByApp(string appNameOrPid) =>
Parse(WinappCli.Invoke("ui", "list-windows", "-a", appNameOrPid, "--json"));
private static IReadOnlyList<WindowInfo> Parse(WinappCli.Result r)
{
if (!r.Success || string.IsNullOrEmpty(r.StdOut))
{
return Array.Empty<WindowInfo>();
}
try
{
using var doc = JsonDocument.Parse(r.StdOut);
if (doc.RootElement.ValueKind != JsonValueKind.Array)
{
return Array.Empty<WindowInfo>();
}
var list = new List<WindowInfo>();
foreach (var w in doc.RootElement.EnumerateArray())
{
list.Add(new WindowInfo(
Hwnd: w.TryGetProperty("hwnd", out var h) && h.ValueKind == JsonValueKind.Number ? h.GetInt64() : 0,
Title: w.TryGetProperty("title", out var t) ? (t.GetString() ?? string.Empty) : string.Empty,
ProcessName: w.TryGetProperty("processName", out var pn) ? (pn.GetString() ?? string.Empty) : string.Empty,
ProcessId: w.TryGetProperty("processId", out var pid) && pid.ValueKind == JsonValueKind.Number ? pid.GetInt32() : 0,
ClassName: w.TryGetProperty("className", out var cn) ? (cn.GetString() ?? string.Empty) : string.Empty,
Width: w.TryGetProperty("width", out var ww) && ww.ValueKind == JsonValueKind.Number ? ww.GetInt32() : 0,
Height: w.TryGetProperty("height", out var hh) && hh.ValueKind == JsonValueKind.Number ? hh.GetInt32() : 0));
}
return list;
}
catch
{
return Array.Empty<WindowInfo>();
}
}
/// <summary>
/// Poll until a window matching <paramref name="predicate"/> appears, or <paramref name="timeoutMS"/>
/// elapses. Returns the window's <see cref="Session"/> wrapper on success.
/// </summary>
public static Session? WaitForWindow(Func<WindowInfo, bool> predicate, PowerToysModule attributeAs = PowerToysModule.Runner, int timeoutMS = 10_000, int pollIntervalMS = 250)
{
var deadline = DateTime.UtcNow + TimeSpan.FromMilliseconds(timeoutMS);
while (DateTime.UtcNow < deadline)
{
foreach (var w in ListAll())
{
Debug.WriteLine(w.ToString());
if (predicate(w))
{
return new Session(attributeAs, w.Hwnd, w.Title, w.ProcessId, w.ProcessName);
}
}
Thread.Sleep(pollIntervalMS);
}
return null;
}
/// <summary>Convenience wrapper: wait for a window with the given title substring.</summary>
public static Session? WaitForWindowByTitle(string titleContains, int timeoutMS = 10_000)
=> WaitForWindow(w => w.Title.Contains(titleContains, StringComparison.OrdinalIgnoreCase), timeoutMS: timeoutMS);
/// <summary>
/// Wait for any window owned by a process whose name contains <paramref name="processNameContains"/>.
/// Uses winappcli's <c>-a</c> filter under the hood so untitled windows (e.g. the ColorPicker
/// editor) are discoverable — the unfiltered <c>list-windows</c> drops those.
/// </summary>
public static Session? WaitForWindowByProcess(string processNameContains, int timeoutMS = 10_000, int pollIntervalMS = 250)
{
var deadline = DateTime.UtcNow + TimeSpan.FromMilliseconds(timeoutMS);
while (DateTime.UtcNow < deadline)
{
foreach (var w in ListByApp(processNameContains))
{
Debug.WriteLine(w.ToString());
return new Session(PowerToysModule.Runner, w.Hwnd, w.Title, w.ProcessId, w.ProcessName);
}
Thread.Sleep(pollIntervalMS);
}
return null;
}
/// <summary>
/// Same as <see cref="WaitForWindowByProcess"/> but filters with <paramref name="predicate"/>.
/// Use when the same process owns multiple windows (e.g. ColorPickerUI exposes both the
/// small picker overlay and the larger editor window).
/// </summary>
public static Session? WaitForWindowByApp(
string appNameOrPid,
Func<WindowInfo, bool> predicate,
int timeoutMS = 10_000,
int pollIntervalMS = 250)
{
var deadline = DateTime.UtcNow + TimeSpan.FromMilliseconds(timeoutMS);
while (DateTime.UtcNow < deadline)
{
foreach (var w in ListByApp(appNameOrPid))
{
Debug.WriteLine(w.ToString());
if (predicate(w))
{
return new Session(PowerToysModule.Runner, w.Hwnd, w.Title, w.ProcessId, w.ProcessName);
}
}
Thread.Sleep(pollIntervalMS);
}
return null;
}
}

View File

@@ -30,12 +30,30 @@ namespace Microsoft.PowerToys.UITest
/// </summary>
public string GetDevelopmentPath()
{
// The test assembly normally lives in <buildRoot>\tests\<project>\<tfm>\, so the build
// output root that holds the module exe is three levels above it. When a test project is
// built with a RuntimeIdentifier (OutputType=Exe for the MTP runner) the output gains an
// extra RID subfolder (<tfm>\win-x64\ or \win-arm64\), pushing the root one level further
// up. Detect that case so the relative path stays correct in both layouts.
string prefix = IsRuntimeIdentifierOutputFolder() ? @"\..\..\..\.." : @"\..\..\..";
if (string.IsNullOrEmpty(SubDirectory))
{
return $@"\..\..\..\{ExecutableName}";
return $@"{prefix}\{ExecutableName}";
}
return $@"\..\..\..\{SubDirectory}\{ExecutableName}";
return $@"{prefix}\{SubDirectory}\{ExecutableName}";
}
// True when the executing assembly sits in a RID-specific output subfolder (e.g. ...\<tfm>\win-x64),
// which a project with a RuntimeIdentifier produces. Used to keep GetDevelopmentPath's relative
// walk-up correct whether or not the RID subfolder is present.
private static bool IsRuntimeIdentifierOutputFolder()
{
var baseDir = AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
var leaf = Path.GetFileName(baseDir);
return leaf.Equals("win-x64", StringComparison.OrdinalIgnoreCase)
|| leaf.Equals("win-arm64", StringComparison.OrdinalIgnoreCase);
}
/// <summary>

View File

@@ -362,14 +362,72 @@ namespace Microsoft.PowerToys.UITest
private void StartWindowsAppDriverApp()
{
// Reuse an already-running WinAppDriver — one started once per job by the pipeline
// ("Start WinAppDriver" step), or by an earlier test in this assembly — instead of killing
// and relaunching it. Only spin up a fresh instance when nothing is listening on :4723.
if (IsWinAppDriverListening())
{
SessionHelper.appDriver ??= Process.GetProcessesByName("WinAppDriver").FirstOrDefault();
return;
}
var winAppDriverProcessInfo = new ProcessStartInfo
{
FileName = "C:\\Program Files (x86)\\Windows Application Driver\\WinAppDriver.exe",
Verb = "runas",
// WinAppDriver ends its Main with "Press ENTER to exit" + Console.ReadLine(). Under the
// Microsoft.Testing.Platform test host the child inherits a stdin that is already at EOF,
// so that read returns immediately and WinAppDriver prints "Exiting..." and dies right
// after it starts listening — which is what forced the previous launch to keep
// relaunching it (and made the very first connection racy). Redirecting stdin and NEVER
// closing the pipe makes that read block, so the server stays alive for the whole test
// process and is reused by every test in this assembly. Redirect requires
// UseShellExecute = false; the default endpoint 127.0.0.1:4723 needs no elevation (only a
// custom IP/port does, per WinAppDriver's docs), so "runas" is not needed.
UseShellExecute = false,
RedirectStandardInput = true,
CreateNoWindow = true,
};
this.ExitExe(winAppDriverProcessInfo.FileName);
SessionHelper.appDriver = Process.Start(winAppDriverProcessInfo);
// Intentionally do NOT close appDriver.StandardInput: the open pipe is exactly what blocks
// WinAppDriver's stdin read and keeps the server alive. The static appDriver reference holds
// the pipe open until the test process exits, at which point WinAppDriver shuts down cleanly.
// WinAppDriver needs a moment to open its HTTP listener on :4723. Connecting immediately races
// that startup, so wait until the port accepts a connection before returning.
WaitForWinAppDriverReady();
}
// True when something is already accepting connections on the WinAppDriver port (127.0.0.1:4723).
private static bool IsWinAppDriverListening()
{
try
{
using var client = new System.Net.Sockets.TcpClient();
client.Connect("127.0.0.1", 4723);
return client.Connected;
}
catch
{
return false;
}
}
private static void WaitForWinAppDriverReady(int timeoutMs = 30000)
{
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
while (DateTime.UtcNow < deadline)
{
if (IsWinAppDriverListening())
{
return;
}
System.Threading.Thread.Sleep(500);
}
}
private void KillPowerToysProcesses()

View File

@@ -0,0 +1,66 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Diagnostics;
using System.Text;
using Microsoft.PowerToys.UITest.Next;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace ScreenRuler.UITests.Next;
/// <summary>
/// Verbose per-test execution log. Every <see cref="Step"/> is timestamped with the elapsed seconds
/// since the test began, echoed to the <see cref="TestContext"/> (so it shows inline in the run
/// output) AND accumulated; <see cref="Save"/> writes the whole thing out as a
/// <c>TestExecutionLog_*.log</c> result artifact for post-mortem on CI. ScreenRuler UI tests run
/// sequentially, so a single ambient instance (see <c>TestHelper</c>) is safe.
/// </summary>
internal sealed class DiagnosticLogger
{
private readonly UITestBase testBase;
private readonly string testName;
private readonly StringBuilder buffer = new();
private readonly Stopwatch stopwatch = Stopwatch.StartNew();
public DiagnosticLogger(UITestBase testBase, string testName)
{
this.testBase = testBase;
this.testName = testName;
Step($"===== {testName}: execution log started =====");
}
/// <summary>Append one timestamped step, echoing it to the TestContext immediately.</summary>
public void Step(string message)
{
var line = $"[+{stopwatch.Elapsed.TotalSeconds,8:F2}s] {message}";
buffer.AppendLine(line);
try
{
testBase.TestContext.WriteLine(line);
}
catch
{
// TestContext can be unavailable late in teardown — the buffered copy is still saved.
}
}
/// <summary>Flush the whole log to a result-attached file artifact (best-effort).</summary>
public void Save()
{
Step($"===== {testName}: execution log ended =====");
try
{
var dir = testBase.TestContext.TestResultsDirectory ?? Path.GetTempPath();
Directory.CreateDirectory(dir);
var safeName = string.Concat((testName ?? "test").Split(Path.GetInvalidFileNameChars()));
var file = Path.Combine(dir, $"TestExecutionLog_{safeName}_{DateTime.Now:yyyyMMdd_HHmmss_fff}.log");
File.WriteAllText(file, buffer.ToString());
testBase.TestContext.AddResultFile(file);
}
catch
{
// Best-effort artifact; the inline TestContext copy is the fallback.
}
}
}

View File

@@ -0,0 +1,47 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Look at Directory.Build.props in root for common stuff as well -->
<Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" />
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0-windows10.0.26100.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<RootNamespace>Microsoft.ScreenRuler.UITests</RootNamespace>
<AssemblyName>ScreenRuler.UITests.Next</AssemblyName>
<!-- Per-monitor (V2) DPI awareness so MouseHelper's SetCursorPos coordinates are PHYSICAL
pixels that match winappcli's reported bounds. Required for coordinate-exact tests
(the Bounds drag measurement). -->
<ApplicationManifest>app.manifest</ApplicationManifest>
<!--
Microsoft.Testing.Platform: same modern runner Directory.Build.props enables for the rest
of the repo, so this test class appears in Test Explorer AND can be run via
`dotnet test` / `dotnet run` / `vstest.console.exe`.
-->
<IsTestingPlatformApplication>true</IsTestingPlatformApplication>
<EnableMSTestRunner>true</EnableMSTestRunner>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
<!-- UI tests need a live desktop; never run them as part of MSBuild. -->
<RunVSTest>false</RunVSTest>
</PropertyGroup>
<!-- Stage the built test app under <Platform>\<Configuration>\tests\ so the UI-tests build
pipeline (CopyFiles glob **/<plat>/<config>/tests/**) picks it up, matching the other
*.UITests projects. Without this it builds to bin\ and is never staged into the artifact. -->
<PropertyGroup>
<OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\tests\ScreenRuler.UITests.Next\</OutputPath>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MSTest" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\common\UITestAutomation.Next\UITestAutomation.Next.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,32 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
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

@@ -0,0 +1,539 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Diagnostics;
using System.Text.RegularExpressions;
using Microsoft.PowerToys.UITest.Next;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace ScreenRuler.UITests.Next;
/// <summary>
/// Shared helpers for the Screen Ruler <c>.Next</c> UI tests. Ported from the legacy
/// <c>ScreenRuler.UITests/TestHelper.cs</c> (WinAppDriver) to the winappcli harness.
/// </summary>
/// <remarks>
/// Key differences from the legacy helper:
/// <list type="bullet">
/// <item><description>The Settings tree is driven through <c>testBase.Session</c> (the Settings
/// window). The Screen Ruler toolbar buttons live in a <b>different process/window</b>
/// (<c>PowerToys.MeasureToolUI</c>), so they're found through a process-scoped
/// <see cref="Session.FromProcess(string, PowerToysModule, int)"/> session — the winappcli
/// equivalent of the legacy <c>global: true</c> Find.</description></item>
/// <item><description>Mouse / keyboard / clipboard go through the static
/// <c>MouseHelper</c> / <c>KeyboardHelper</c> / <c>ClipboardHelper</c> instead of instance
/// methods on <c>Session</c> / <c>UITestBase</c>.</description></item>
/// </list>
/// </remarks>
public static class TestHelper
{
private static readonly string[] ShortcutSeparators = { " + ", "+", " " };
// Button automation ids from the Measure Tool's Resources.resw.
public const string BoundsButtonId = "Button_Bounds";
public const string SpacingButtonName = "Button_Spacing";
public const string HorizontalSpacingButtonName = "Button_SpacingHorizontal";
public const string VerticalSpacingButtonName = "Button_SpacingVertical";
public const string CloseButtonId = "Button_Close";
// The Measure Tool UI process (the toolbar + measurement overlays). NOTE: the window TITLE is
// "PowerToys.ScreenRuler", but the PROCESS name winappcli's -a flag needs is "PowerToys.MeasureToolUI".
public const string ScreenRulerProcess = "PowerToys.MeasureToolUI";
// The module's key in the global settings.json "enabled" section (note the space). Pass this to
// the UITestBase ctor's enableModules so the runner boots ONLY this module — much faster on a
// fresh profile (CI), where otherwise all ~30 modules start, and more isolated (no other module's
// hotkeys/overlays interfere).
public const string ModuleSettingsKey = "Measure Tool";
// Ambient per-test diagnostics. ScreenRuler UI tests run sequentially, so a single ambient
// instance is safe. The logger is created in InitializeTest and flushed (as a TestExecutionLog
// artifact) in CleanupTest; Log(...) is a no-op when no test is active.
private static DiagnosticLogger? log;
/// <summary>Append a verbose, timestamped step to the current test's execution log.</summary>
private static void Log(string message) => log?.Step(message);
/// <summary>Navigate to the Screen Ruler settings page, enable the toggle, and read the shortcut.</summary>
public static Key[] InitializeTest(UITestBase testBase, string testName)
{
log = new DiagnosticLogger(testBase, testName);
Log("InitializeTest: navigating to the Screen Ruler settings page");
LaunchFromSetting(testBase);
Log("InitializeTest: enabling the Screen Ruler toggle");
var toggleSwitch = SetScreenRulerToggle(testBase, enable: true);
Assert.IsTrue(toggleSwitch.IsOn, $"Screen Ruler toggle switch should be ON for {testName}");
var activationKeys = ReadActivationShortcut(testBase);
Assert.IsNotNull(activationKeys, "Should be able to read activation shortcut");
Assert.IsTrue(activationKeys.Length > 0, "Activation shortcut should contain at least one key");
Log($"InitializeTest: ready; activation shortcut = {string.Join(" + ", activationKeys)}");
return activationKeys;
}
/// <summary>Close the Screen Ruler UI (best-effort) and flush the execution-log artifact.</summary>
public static void CleanupTest(UITestBase testBase)
{
try
{
Log("CleanupTest: closing the Screen Ruler UI");
CloseScreenRulerUI(testBase);
}
finally
{
log?.Save();
log = null;
}
}
/// <summary>Navigate to the Screen Ruler (Measure Tool) settings page.</summary>
public static void LaunchFromSetting(UITestBase testBase)
{
// The "System Tools" group is collapsed by default, so the Screen Ruler child item isn't in
// the tree until the group is expanded. Expand it only when the child isn't already present.
if (!testBase.Session.Has(By.AccessibilityId("ScreenRulerNavItem"), 500))
{
testBase.Session.Find<NavigationViewItem>(By.AccessibilityId("SystemToolsNavItem"), 5000).Click(msPostAction: 500);
}
testBase.Session.Find<NavigationViewItem>(By.AccessibilityId("ScreenRulerNavItem"), 5000).Click(msPostAction: 800);
}
/// <summary>Set the Screen Ruler toggle to the requested state.</summary>
public static ToggleSwitch SetScreenRulerToggle(UITestBase testBase, bool enable)
{
var toggleSwitch = testBase.Session.Find<ToggleSwitch>(By.AccessibilityId("Toggle_ScreenRuler"), 5000);
toggleSwitch.Toggle(enable);
toggleSwitch.WaitForProperty("ToggleState", enable ? "On" : "Off", 5000);
return toggleSwitch;
}
/// <summary>Set the Screen Ruler toggle and assert it reached the requested state.</summary>
public static void SetAndVerifyScreenRulerToggle(UITestBase testBase, bool enable, string testName)
{
var toggleSwitch = SetScreenRulerToggle(testBase, enable);
Assert.AreEqual(enable, toggleSwitch.IsOn, $"Screen Ruler toggle switch should be {(enable ? "ON" : "OFF")} for {testName}");
}
/// <summary>
/// Read the activation shortcut straight from the Settings window's ShortcutControl — the
/// EditButton's UIA HelpText, which the control sets to the live shortcut (e.g.
/// "Win + Ctrl + Shift + M"). Polls until the window reports a real shortcut (a chord that
/// includes a non-modifier key) rather than the "Configure shortcut" placeholder or a transient
/// empty value while the page is still binding. Never substitutes a hard-coded default: the test
/// must send exactly what the module is bound to, because a wrong/stale default would silently
/// fail to activate and mask the real problem.
/// </summary>
public static Key[] ReadActivationShortcut(UITestBase testBase)
{
var shortcutCard = testBase.Session.Find<Element>(By.AccessibilityId("Shortcut_ScreenRuler"), 5000);
var shortcutButton = shortcutCard.Find<Element>(By.AccessibilityId("EditButton"), 5000);
string helpText = string.Empty;
var deadline = DateTime.UtcNow.AddMilliseconds(5000);
do
{
helpText = shortcutButton.HelpText ?? string.Empty;
var keys = ParseShortcutText(helpText);
if (HasMainKey(keys))
{
testBase.TestContext.WriteLine($"Activation shortcut read from Settings: '{helpText}'.");
return keys;
}
Thread.Sleep(200);
}
while (DateTime.UtcNow < deadline);
Assert.Fail(
$"Could not read the Screen Ruler activation shortcut from the Settings window: the " +
$"ShortcutControl EditButton HelpText was '{helpText}' (expected a chord such as " +
$"'Win + Ctrl + Shift + M'). Refusing to fall back to a hard-coded default.");
return Array.Empty<Key>(); // unreachable — Assert.Fail throws.
}
/// <summary>
/// Parse a shortcut string like "Win + Ctrl + Shift + M" into a <see cref="Key"/> chord (note:
/// "win" maps to <see cref="Key.LWin"/>). Returns exactly the keys present — NO default
/// substitution; the caller decides whether the result is a usable shortcut.
/// </summary>
public static Key[] ParseShortcutText(string shortcutText)
{
var keys = new List<Key>();
if (string.IsNullOrEmpty(shortcutText))
{
return keys.ToArray();
}
foreach (var part in shortcutText.Split(ShortcutSeparators, StringSplitOptions.RemoveEmptyEntries))
{
var key = ParseKeyToken(part);
if (key.HasValue)
{
keys.Add(key.Value);
}
}
return keys.ToArray();
}
/// <summary>Map one display token ("Win"/"Ctrl"/"Shift"/"Alt", a letter, a digit, "F5", "Space"…) to a <see cref="Key"/>.</summary>
private static Key? ParseKeyToken(string token)
{
var t = token.Trim();
if (t.Length == 0)
{
return null;
}
switch (t.ToLowerInvariant())
{
case "win":
case "windows":
return Key.LWin;
case "ctrl":
case "control":
return Key.Ctrl;
case "shift":
return Key.Shift;
case "alt":
return Key.Alt;
}
// Single digit 0-9 → enum names Num0..Num9.
if (t.Length == 1 && t[0] >= '0' && t[0] <= '9')
{
return Enum.TryParse<Key>("Num" + t, out var num) ? num : null;
}
// Letters, function keys ("F5") and named keys ("Space"/"Enter"/"Esc"/"Tab"/"Home"…) match the
// Key enum names. Require a leading letter so numeric strings aren't cast straight to enum values.
if (char.IsLetter(t[0]) && Enum.TryParse<Key>(t, ignoreCase: true, out var k))
{
return k;
}
return null;
}
/// <summary>True when the chord includes a non-modifier (main) key — i.e. a real, activatable shortcut.</summary>
private static bool HasMainKey(Key[] keys) =>
keys.Any(k => k is not (Key.LWin or Key.Ctrl or Key.Shift or Key.Alt));
/// <summary>
/// True when the Measure Tool UI is up. Uses a Win32 PROCESS check, NOT winappcli's
/// <c>list-windows</c>: enumerating the live/frozen overlay's UIA tree costs seconds on CI (and can
/// hang). MeasureToolUI exists only while the ruler is open, so process-presence is an accurate,
/// instant, hang-free proxy.
/// </summary>
public static bool IsScreenRulerUIOpen(UITestBase testBase) =>
Process.GetProcessesByName(ScreenRulerProcess).Length > 0;
/// <summary>Poll until the Measure Tool UI reaches the requested presence.</summary>
public static bool WaitForScreenRulerUIState(UITestBase testBase, bool shouldBeOpen, int timeoutMs = 5000, int pollingIntervalMs = 100)
{
var endTime = DateTime.UtcNow.AddMilliseconds(timeoutMs);
while (DateTime.UtcNow < endTime)
{
if (IsScreenRulerUIOpen(testBase) == shouldBeOpen)
{
return true;
}
Thread.Sleep(pollingIntervalMs);
}
return false;
}
public static bool WaitForScreenRulerUI(UITestBase testBase, int timeoutMs = 5000) =>
WaitForScreenRulerUIState(testBase, shouldBeOpen: true, timeoutMs);
public static bool WaitForScreenRulerUIToDisappear(UITestBase testBase, int timeoutMs = 5000) =>
WaitForScreenRulerUIState(testBase, shouldBeOpen: false, timeoutMs);
/// <summary>
/// Close the Measure Tool UI via Win32 — gracefully (WM_CLOSE to the main window), then kill as a
/// last resort. Deliberately avoids winappcli: a process-scoped <see cref="Session.FromProcess"/>,
/// the Close-button search, and <c>list-windows</c> all walk the live/frozen overlay's UIA tree,
/// which costs 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

@@ -0,0 +1,64 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.PowerToys.UITest.Next;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace ScreenRuler.UITests.Next;
[TestClass]
public class TestShortcutActivation : UITestBase
{
public TestShortcutActivation()
: base(PowerToysModule.PowerToysSettings, WindowSize.Large, enableModules: new[] { TestHelper.ModuleSettingsKey })
{
}
[TestMethod]
[TestCategory("Activation")]
public void TestScreenRulerShortcutActivation()
{
var activationKeys = TestHelper.InitializeTest(this, "activation test");
try
{
// Test 1: pressing the activation shortcut shows the toolbar.
Assert.IsTrue(
TestHelper.SendShortcutUntilVisible(this, activationKeys),
$"ScreenRulerUI should appear after pressing activation shortcut: {string.Join(" + ", activationKeys)}");
// Test 2: pressing the activation shortcut again hides the toolbar (it's a toggle).
KeyboardHelper.SendKeys(activationKeys);
Assert.IsTrue(
TestHelper.WaitForScreenRulerUIToDisappear(this, 3000),
$"ScreenRulerUI should disappear after pressing activation shortcut again: {string.Join(" + ", activationKeys)}");
// Test 3: while disabled, the shortcut must not activate the utility.
// testBase.Session already targets the Settings window, so no re-attach is needed
// (winappcli targets by hwnd/process, not foreground).
TestHelper.SetAndVerifyScreenRulerToggle(this, enable: false, "disabled state test");
KeyboardHelper.SendKeys(activationKeys);
Thread.Sleep(1500);
Assert.IsFalse(
TestHelper.IsScreenRulerUIOpen(this),
"ScreenRulerUI should not appear when Screen Ruler is disabled");
// Test 4: re-enable and confirm the shortcut activates it again.
TestHelper.SetAndVerifyScreenRulerToggle(this, enable: true, "re-enabled state test");
Assert.IsTrue(
TestHelper.SendShortcutUntilVisible(this, activationKeys),
$"ScreenRulerUI should appear after re-enabling and pressing activation shortcut: {string.Join(" + ", activationKeys)}");
// Test 5: the utility can be closed via the cleanup helper.
TestHelper.CloseScreenRulerUI(this);
Assert.IsTrue(
TestHelper.WaitForScreenRulerUIToDisappear(this, 3000),
"ScreenRulerUI should close after calling CloseScreenRulerUI");
}
finally
{
TestHelper.CleanupTest(this);
}
}
}

View File

@@ -0,0 +1,32 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
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

@@ -0,0 +1,32 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.PowerToys.UITest.Next;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace ScreenRuler.UITests.Next;
[TestClass]
public class TestSpacingHorizontal : UITestBase
{
public TestSpacingHorizontal()
: base(PowerToysModule.PowerToysSettings, WindowSize.Large, enableModules: new[] { TestHelper.ModuleSettingsKey })
{
}
[TestMethod]
[TestCategory("Spacing")]
public void TestScreenRulerHorizontalSpacingTool()
{
TestHelper.InitializeTest(this, "horizontal spacing test");
try
{
TestHelper.PerformSpacingToolTest(this, TestHelper.HorizontalSpacingButtonName, "Horizontal Spacing");
}
finally
{
TestHelper.CleanupTest(this);
}
}
}

View File

@@ -0,0 +1,32 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
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

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="ScreenRuler.UITests.Next.app"/>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- Windows 10+ feature support for unpackaged apps. -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
<!--
Per-monitor (V2) DPI awareness. REQUIRED for coordinate-exact UI tests: without it the test
host is DPI-unaware, so SetCursorPos / GetCursorPos coordinates (used by MouseHelper) are
virtualized by the display scale factor and no longer match the PHYSICAL pixels winappcli
reports. On a 150%-scaled display that turned a 99px drag into a ~149px measurement
(Screen Ruler Bounds reported "150 x 149" instead of "100 x 100").
-->
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
</windowsSettings>
</application>
</assembly>

View File

@@ -0,0 +1,10 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.VisualStudio.TestTools.UnitTesting;
// UI tests share global desktop state — the same Settings window, the same clipboard, the same
// foreground focus. Parallel execution against shared state is a recipe for non-determinism.
// MSTest defaults to parallel-by-method inside an assembly; pin to sequential here.
[assembly: DoNotParallelize]

View File

@@ -0,0 +1,42 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Look at Directory.Build.props in root for common stuff as well -->
<Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" />
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0-windows10.0.26100.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<RootNamespace>Microsoft.ColorPicker.UITests</RootNamespace>
<AssemblyName>ColorPicker.UITests</AssemblyName>
<!--
Microsoft.Testing.Platform: same modern runner Directory.Build.props enables for the rest
of the repo, so this test class appears in Test Explorer AND can be run via
`dotnet test` / `dotnet run` / `vstest.console.exe`.
-->
<IsTestingPlatformApplication>true</IsTestingPlatformApplication>
<EnableMSTestRunner>true</EnableMSTestRunner>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
<!-- UI tests need a live desktop; never run them as part of MSBuild. -->
<RunVSTest>false</RunVSTest>
</PropertyGroup>
<!-- Stage the built test app under <Platform>\<Configuration>\tests\ so the UI-tests build
pipeline (CopyFiles glob **/<plat>/<config>/tests/**) picks it up, matching the other
*.UITests projects. Without this it builds to bin\ and is never staged into the artifact. -->
<PropertyGroup>
<OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\tests\ColorPicker.UITests\</OutputPath>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MSTest" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\common\UITestAutomation.Next\UITestAutomation.Next.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,446 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Diagnostics;
using System.Text.Json;
using Microsoft.PowerToys.UITest.Next;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.ColorPicker.UITests;
/// <summary>
/// Full end-to-end Color Picker scenario, driven entirely through the Settings UI:
/// 1. From the Settings app, navigate to the Color Picker page via the utilities stack.
/// 2. On the page, toggle the module OFF and verify <c>PowerToys.ColorPickerUI</c> exits.
/// 3. Toggle it back ON and verify <c>PowerToys.ColorPickerUI</c> respawns.
/// 4. Read the activation shortcut from the page's <c>ShortcutControl</c> (the EditButton
/// exposes <c>HotkeySettings.ToString()</c> via <c>AutomationProperties.HelpText</c>).
/// 5. Clear the clipboard, move the cursor, send the shortcut chord.
/// 6. Wait for the picker overlay window and read the displayed HEX from the overlay's
/// automation-peer TextBlock (AutomationId="ColorHexAutomationPeer").
/// 7. Left-click to capture. ColorPicker writes the captured color to the clipboard.
/// 8. Read the captured value from the clipboard and assert it matches the overlay HEX.
/// 9. Wait for the editor window and assert the captured value appears in its tree.
/// </summary>
/// <remarks>
/// The overlay's visible ColorTextBlock has <c>AutomationProperties.Name="{Binding ColorName}"</c>
/// so UIA exposes the friendly color name (e.g. "White"), not the HEX. To work around that,
/// MainView.xaml carries a hidden sibling TextBlock bound to <c>ColorText</c> with
/// <c>AutomationId="ColorHexAutomationPeer"</c> — a test-only UIA hook that lets us read the
/// actually-displayed HEX value without affecting the visual layout or accessibility UX.
/// </remarks>
[TestClass]
public class ColorPickerEndToEndTests : UITestBase
{
public ColorPickerEndToEndTests()
: base(PowerToysModule.PowerToysSettings, enableModules: new[] { "ColorPicker" })
{
}
[TestMethod]
[TestCategory("ColorPicker")]
[TestCategory("winappcli-POC")]
public void NavigateReadShortcutActivateAndCapture()
{
try
{
RunTest();
}
finally
{
// Universal cleanup: close any leftover ColorPicker window (overlay or editor),
// then close the Settings window. Tolerant — never throws so it can't mask the
// real test failure.
WindowControl.TryCloseByApp("PowerToys.ColorPickerUI");
WindowControl.TryCloseByApp("PowerToys.Settings");
}
}
private void RunTest()
{
// -- 1. Navigate via the utilities stack on the right of the dashboard ----------------
// The Dashboard's right-side ModuleList renders each utility as a clickable SettingsCard
// whose header is a TextBlock with the module's Label (e.g. "Color Picker"). The
// SettingsCard itself isn't surfaced by name "Color Picker" in winappcli's search — only
// its inner TextBlock label is — and the TextBlock has no InvokePattern (the click is
// handled by the SettingsCard's OnSettingsCardClick).
//
// A "Color Picker" search returns 4 elements: the Quick-Access tile (Button) and its
// label (TextBlock with invokableAncestor) on the left, plus the utility-stack label
// (TextBlock) and ToggleSwitch on the right. We pick the rightmost TextBlock (largest
// X coordinate) — that's the utility-stack label — and mouse-click it (winapp ui click
// uses real mouse simulation, which triggers the ancestor SettingsCard's click).
var matches = Session.FindAll<Element>(By.Name("Color Picker"));
TestContext.WriteLine($"'Color Picker' search returned {matches.Count} elements:");
foreach (var m in matches)
{
TestContext.WriteLine($" [{m.ControlType,-10}] class='{m.ClassName}' at ({m.X},{m.Y}) {m.Width}x{m.Height} sel='{m.Selector}'");
}
var utilityItem = matches
.Where(m => m.ClassName.Equals("TextBlock", StringComparison.OrdinalIgnoreCase))
.OrderByDescending(m => m.X)
.FirstOrDefault();
Assert.IsNotNull(
utilityItem,
"Could not find a 'Color Picker' TextBlock to click. Is the dashboard visible? See element dump above.");
TestContext.WriteLine($"Clicking utility-stack 'Color Picker' TextBlock at x={utilityItem!.X}, y={utilityItem.Y}");
utilityItem.MouseClick(msPostAction: 800);
TestContext.WriteLine("Navigated to Color Picker page (clicked utility-stack item).");
// -- 2. Find the page-level enable toggle ---------------------------------------------
// After navigation, the dashboard is gone and the page's enable toggle is the only
// "Color Picker" ToggleSwitch in the tree. The ToggleSwitch wrapper pins
// ClassName="ToggleSwitch" so the search is unambiguous.
var toggle = Find<ToggleSwitch>(By.Name("Color Picker"));
var initialIsOn = toggle.IsOn;
TestContext.WriteLine($"Initial toggle state: IsOn={initialIsOn}");
try
{
// -- 3. Toggle the module OFF and verify the runner terminates ColorPickerUI -----
// If currently OFF, prime ON first so OFF→ON→OFF gives us a real lifecycle signal.
if (!toggle.IsOn)
{
toggle.Toggle(true);
Assert.IsTrue(
toggle.WaitForProperty("ToggleState", "On", timeoutMS: 5_000),
"Priming: toggle UI did not flip to On.");
Assert.IsTrue(
WaitForProcess("PowerToys.ColorPickerUI", expected: true, timeoutMS: 10_000),
"Priming: PowerToys.ColorPickerUI did not start after enabling.");
}
toggle.Toggle(false);
Assert.IsTrue(
toggle.WaitForProperty("ToggleState", "Off", timeoutMS: 5_000),
"Toggle UI did not flip to Off.");
Assert.IsTrue(
WaitForProcess("PowerToys.ColorPickerUI", expected: false, timeoutMS: 10_000),
"PowerToys.ColorPickerUI did not exit within 10s after toggling module OFF.");
TestContext.WriteLine("Toggled OFF; ColorPickerUI process exited.");
// -- 4. Toggle the module ON and verify the runner respawns ColorPickerUI -------
toggle.Toggle(true);
Assert.IsTrue(
toggle.WaitForProperty("ToggleState", "On", timeoutMS: 5_000),
"Toggle UI did not flip to On.");
Assert.IsTrue(
WaitForProcess("PowerToys.ColorPickerUI", expected: true, timeoutMS: 10_000),
"PowerToys.ColorPickerUI did not start within 10s after toggling module ON.");
TestContext.WriteLine("Toggled ON; ColorPickerUI process running.");
// -- 5. Read the activation shortcut from the UI --------------------------------
// ShortcutControl renders the current shortcut on an inner Button (x:Name="EditButton")
// whose AutomationProperties.HelpText is set to HotkeySettings.ToString() (e.g.
// "Win + Shift + C"). x:Name reflects as the UIA AutomationId in WinUI when no
// explicit AutomationId is set, so we look it up by that.
var editButton = Find<Button>(By.AccessibilityId("EditButton"));
var shortcutText = editButton.HelpText;
TestContext.WriteLine($"Activation shortcut (from EditButton HelpText): '{shortcutText}'");
Assert.IsFalse(
string.IsNullOrWhiteSpace(shortcutText),
"Could not read activation shortcut HelpText from the ShortcutControl EditButton.");
var keys = ParseShortcutText(shortcutText);
Assert.IsTrue(
keys.Length > 0,
$"Could not parse any keys from shortcut text '{shortcutText}'.");
TestContext.WriteLine($"Parsed key chord: [{string.Join(", ", keys)}]");
// -- 6. Clear the clipboard and park the cursor ---------------------------------
// ClipboardHelper.Clear runs the Clipboard call on an STA thread (required by
// System.Windows.Forms.Clipboard) and swallows any contention errors.
var seedClipboard = ClipboardHelper.GetText();
ClipboardHelper.Clear();
TestContext.WriteLine($"Cleared clipboard. (Previous content was {seedClipboard.Length} chars.)");
var screen = System.Windows.Forms.SystemInformation.PrimaryMonitorSize;
int cx = screen.Width / 2;
int cy = screen.Height / 2;
MouseHelper.MoveTo(cx, cy);
TestContext.WriteLine($"Cursor parked at ({cx}, {cy}) — primary screen center.");
// -- 7+8. Activate via the shortcut, then wait for the picker overlay ------------
// The overlay (ColorPickerUI's MainWindow) is a small, LAYERED, transparent, topmost,
// no-taskbar window. Once activated it stays visible and follows the cursor until a
// click/Esc, so normally it shows immediately and winapp enumerates it WITHOUT any
// cursor movement (the common path locally and on most agents). Intermittently on a
// slow/loaded agent, winapp lists ZERO ColorPickerUI windows even though the runner
// logged the hotkey firing and ColorPicker activated — i.e. the overlay never reached a
// UIA-visible, on-screen state. Two mitigations, applied per attempt:
// * Poll patiently BEFORE re-sending: re-issuing the chord runs
// StartUserSession -> EndUserSession, which HIDES then re-shows the overlay, so the
// old 2s re-send cadence churned the window and a slow winapp poll kept missing it.
// * Reposition the cursor once mid-wait: ColorPicker re-positions + re-renders the
// overlay at the live cursor, recovering it if it landed off-screen/uncomposited.
// We still retry because the very first chord can be lost if the runner hasn't finished
// arming its WH_KEYBOARD_LL hook. The overlay is ~120x64 (vs the ~660x570 editor), so
// filter by size; the cursor settles on a stable pixel for the later HEX read + click.
const int activationAttempts = 3;
Session? overlay = null;
for (int attempt = 1; attempt <= activationAttempts && overlay is null; attempt++)
{
TestContext.WriteLine($"Sending activation chord [{string.Join(", ", keys)}] (attempt {attempt}/{activationAttempts}).");
KeyboardHelper.SendKeys(keys);
overlay = WindowsFinder.WaitForWindowByApp(
"PowerToys.ColorPickerUI", w => w.Width < 300 && w.Height < 200, timeoutMS: 2_500);
if (overlay is null)
{
// Recovery kick: nudge the overlay to a fresh on-screen spot, then keep polling
// before re-sending (which would hide/re-show it and restart the churn).
MouseHelper.MoveTo(cx + 60, cy + 60);
overlay = WindowsFinder.WaitForWindowByApp(
"PowerToys.ColorPickerUI", w => w.Width < 300 && w.Height < 200, timeoutMS: 2_500);
}
}
if (overlay is null)
{
var dump = string.Join(
Environment.NewLine,
WindowsFinder.ListByApp("PowerToys.ColorPickerUI")
.Select(w => $" hwnd={w.Hwnd} title='{w.Title}' class='{w.ClassName}' size={w.Width}x{w.Height}"));
Assert.Fail(
$"Picker overlay did not appear after {activationAttempts} shortcut attempts." + Environment.NewLine +
" The hotkey DID reach the runner (it logs 'ColorPicker hotkey is invoked') and ColorPicker" + Environment.NewLine +
" activated, so this is the overlay (a small layered/transparent/topmost window) failing to" + Environment.NewLine +
" become UIA-visible/on-screen on this agent — a rendering/enumeration issue, not input." + Environment.NewLine +
" Current ColorPickerUI windows:" + Environment.NewLine +
(dump.Length > 0 ? dump : " (none)"));
}
TestContext.WriteLine($"Picker overlay appeared: hwnd={overlay!.WindowHandle}");
// -- 9. Read the displayed HEX from the overlay's automation-peer TextBlock -----
// The peer is a Visibility=Visible, Opacity=0 TextBlock added to MainView.xaml
// specifically so UIA-driven tests can read the live HEX value. It is bound to
// the same `ColorText` source as the visible TextBlock, so it always matches
// what the user sees.
string overlayHex = string.Empty;
try
{
var peer = overlay.Find(By.AccessibilityId("ColorHexAutomationPeer"), timeoutMS: 2_000);
overlayHex = peer.Name;
TestContext.WriteLine($"Overlay HEX (from automation peer): '{overlayHex}'");
}
catch (Exception ex)
{
TestContext.WriteLine($"Could not read ColorHexAutomationPeer: {ex.Message}");
}
Assert.IsFalse(
string.IsNullOrEmpty(overlayHex),
"Failed to read the overlay's HEX value from the ColorHexAutomationPeer TextBlock.");
// -- 10. Click to capture; ColorPicker writes the configured format to clipboard
MouseHelper.LeftClick();
TestContext.WriteLine("Sent left-click to capture color.");
var capturedColor = ClipboardHelper.WaitForText(ignoredValue: string.Empty, timeoutMS: 3_000);
Assert.IsFalse(
string.IsNullOrEmpty(capturedColor),
"Nothing was written to the clipboard within 3s after the click. " +
"Did the picker actually capture? (Check that left-click is mapped to a 'PickColor' action.)");
TestContext.WriteLine($"Captured color (clipboard): '{capturedColor}'");
// Cross-check: the clipboard value should be the same HEX the overlay was showing.
// Both come from `ColorText` in MainViewModel, just routed differently (overlay
// binding vs. ColorPickerHelper.CopyToClipboard on Picker_MouseDown).
Assert.IsTrue(
ContainsIgnoringHash(capturedColor, overlayHex) || ContainsIgnoringHash(overlayHex, capturedColor),
$"Overlay HEX '{overlayHex}' and clipboard '{capturedColor}' don't match.");
TestContext.WriteLine("Overlay HEX matches clipboard value.");
// -- 11. Wait for the editor window ---------------------------------------------
var editor = WindowsFinder.WaitForWindowByApp(
"PowerToys.ColorPickerUI",
w => w.Width > 300 && w.Height > 300,
timeoutMS: 10_000);
if (editor is null)
{
var dump = string.Join(
Environment.NewLine,
WindowsFinder.ListByApp("PowerToys.ColorPickerUI")
.Select(w => $" hwnd={w.Hwnd} title='{w.Title}' class='{w.ClassName}' size={w.Width}x{w.Height}"));
Assert.Fail(
"ColorPicker editor window did not appear within 10s after the click." + Environment.NewLine +
" Current ColorPickerUI windows:" + Environment.NewLine +
(dump.Length > 0 ? dump : " (none)"));
}
TestContext.WriteLine($"Editor window: hwnd={editor!.WindowHandle} title='{editor.WindowTitle}'");
// -- 12. Find the captured color inside the editor's tree ------------------------
// From ColorEditorView.xaml the format list is populated from `ColorRepresentations`.
// Each format renders as a ColorFormatControl (DataItem in the UIA tree) that
// contains a TextBox holding the formatted color string. The captured clipboard
// value will be ONE of those formats — we just need to find any element whose Name
// or Value contains it.
var tree = editor.Inspect(depth: 12);
var values = new List<(string Type, string Name, string Value)>();
WalkElements(tree, values);
TestContext.WriteLine($"Editor exposed {values.Count} elements. First 40:");
foreach (var v in values.Take(40))
{
TestContext.WriteLine($" [{v.Type,-12}] name='{v.Name}' value='{v.Value}'");
}
Assert.IsTrue(values.Count > 0, "Editor reported no readable elements via inspect --json.");
// Match: find any element whose Name or Value contains the clipboard text
// case-insensitively. If the clipboard had a '#' prefix (e.g. "#FFFFFF") and the
// editor renders without it, also try the bare-hex form.
var needle = capturedColor.Trim();
var needleBareHex = needle.TrimStart('#');
var match = values.FirstOrDefault(v =>
v.Name.Contains(needle, StringComparison.OrdinalIgnoreCase) ||
v.Value.Contains(needle, StringComparison.OrdinalIgnoreCase) ||
(needleBareHex.Length > 0 &&
(v.Name.Contains(needleBareHex, StringComparison.OrdinalIgnoreCase) ||
v.Value.Contains(needleBareHex, StringComparison.OrdinalIgnoreCase))));
if (string.IsNullOrEmpty(match.Name) && string.IsNullOrEmpty(match.Value))
{
Assert.Fail(
$"Captured color '{capturedColor}' not found in editor tree." + Environment.NewLine +
" See element dump above.");
}
TestContext.WriteLine(
$"MATCH: captured '{capturedColor}' found in editor element [{match.Type}] Name='{match.Name}' Value='{match.Value}'");
}
finally
{
// Restore the toggle to its initial state regardless of pass/fail. Best-effort so
// a cleanup failure can't mask the real test failure.
try
{
if (toggle.IsOn != initialIsOn)
{
toggle.Toggle(initialIsOn);
}
}
catch
{
}
}
}
/// <summary>
/// Case-insensitive substring comparison that ignores a leading <c>#</c> on either side.
/// Used to cross-check the overlay HEX against the clipboard value when only one of them
/// carries the prefix.
/// </summary>
private static bool ContainsIgnoringHash(string haystack, string needle)
{
if (string.IsNullOrEmpty(haystack) || string.IsNullOrEmpty(needle))
{
return false;
}
return haystack.TrimStart('#').Contains(needle.TrimStart('#'), StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Parse a UI-rendered shortcut string like <c>"Win + Shift + C"</c> into the
/// <see cref="Key"/> sequence the harness's keyboard helper expects. Matches the parser
/// pattern used by <c>ScreenRuler.UITests/TestHelper.cs</c>.
/// </summary>
private static Key[] ParseShortcutText(string shortcutText)
{
var separators = new[] { " + ", "+", " " };
var parts = shortcutText.Split(separators, StringSplitOptions.RemoveEmptyEntries);
var keys = new List<Key>();
foreach (var raw in parts)
{
var part = raw.Trim().ToLowerInvariant();
Key? key = part switch
{
"win" or "windows" => Key.LWin,
"ctrl" or "control" => Key.Ctrl,
"shift" => Key.Shift,
"alt" => Key.Alt,
_ when part.Length == 1 && part[0] >= 'a' && part[0] <= 'z' =>
(Key)Enum.Parse(typeof(Key), part.ToUpperInvariant()),
_ => null,
};
if (key.HasValue)
{
keys.Add(key.Value);
}
}
return keys.ToArray();
}
/// <summary>Poll <see cref="Process.GetProcessesByName"/> until presence matches <paramref name="expected"/>.</summary>
private static bool WaitForProcess(string name, bool expected, int timeoutMS)
{
var deadline = DateTime.UtcNow + TimeSpan.FromMilliseconds(timeoutMS);
while (DateTime.UtcNow < deadline)
{
var running = Process.GetProcessesByName(name).Length > 0;
if (running == expected)
{
return true;
}
Thread.Sleep(250);
}
return false;
}
/// <summary>
/// Walk the nested <c>inspect --json</c> tree and collect every element with a non-empty
/// name or value. Output shape (from winappcli):
/// <c>{ "windows": [{ "elements": [{ "type", "name", "value", "children": [...] }] }] }</c>.
/// </summary>
private static void WalkElements(JsonElement root, List<(string Type, string Name, string Value)> sink)
{
if (!root.TryGetProperty("windows", out var windows) || windows.ValueKind != JsonValueKind.Array)
{
return;
}
foreach (var w in windows.EnumerateArray())
{
if (w.TryGetProperty("elements", out var els) && els.ValueKind == JsonValueKind.Array)
{
foreach (var el in els.EnumerateArray())
{
Walk(el, sink);
}
}
}
}
private static void Walk(JsonElement el, List<(string Type, string Name, string Value)> sink)
{
var type = el.TryGetProperty("type", out var t) ? (t.GetString() ?? string.Empty) : string.Empty;
var name = el.TryGetProperty("name", out var n) ? (n.GetString() ?? string.Empty) : string.Empty;
var value = el.TryGetProperty("value", out var v) ? (v.GetString() ?? string.Empty) : string.Empty;
if (!string.IsNullOrEmpty(name) || !string.IsNullOrEmpty(value))
{
sink.Add((type, name, value));
}
if (el.TryGetProperty("children", out var ch) && ch.ValueKind == JsonValueKind.Array)
{
foreach (var c in ch.EnumerateArray())
{
Walk(c, sink);
}
}
}
}

View File

@@ -26,6 +26,20 @@
</Border.Effect>-->
<Grid>
<!--
UIA test hook. Mirrors the displayed HEX value (ColorText) into a
transparent TextBlock with a stable AutomationId so automated UI tests
can read the picker's current color without depending on the visible
TextBlock, whose AutomationProperties.Name is bound to ColorName (the
friendly name) for screen-reader UX and therefore masks the HEX from UIA.
-->
<TextBlock
x:Name="ColorHexAutomationPeer"
AutomationProperties.AutomationId="ColorHexAutomationPeer"
IsHitTestVisible="False"
Opacity="0"
Text="{Binding ColorText}" />
<!-- only color format - one line -->
<Grid Margin="2" Visibility="{Binding ShowColorName, Converter={StaticResource bool2InvertedVisibilityConverter}}">
<Grid.ColumnDefinitions>

View File

@@ -1,16 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.PowerToys.UITest;
namespace Microsoft.ColorPicker.UITests
{
public class ColorPickerUITest : UITestBase
{
public ColorPickerUITest()
: base(PowerToysModule.Runner)
{
}
}
}

View File

@@ -1,29 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Look at Directory.Build.props in root for common stuff as well -->
<Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" />
<PropertyGroup>
<ProjectGuid>{6880CE86-5B71-4440-9795-79A325F95747}</ProjectGuid>
<RootNamespace>Microsoft.ColorPicker.UITests</RootNamespace>
<IsPackable>false</IsPackable>
<Nullable>enable</Nullable>
<OutputType>Library</OutputType>
<!-- This is a UI test, so don't run as part of MSBuild -->
<RunVSTest>false</RunVSTest>
</PropertyGroup>
<PropertyGroup>
<OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\tests\UITests-ColorPicker\</OutputPath>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Appium.WebDriver" />
<PackageReference Include="MSTest" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\common\UITestAutomation\UITestAutomation.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,42 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Look at Directory.Build.props in root for common stuff as well -->
<Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" />
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0-windows10.0.26100.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<RootNamespace>Microsoft.Settings.UITests</RootNamespace>
<AssemblyName>Settings.UITests</AssemblyName>
<!--
Microsoft.Testing.Platform: same modern runner Directory.Build.props enables for the rest
of the repo, so this test class appears in Test Explorer AND can be run via
`dotnet test` / `dotnet run` / `vstest.console.exe`.
-->
<IsTestingPlatformApplication>true</IsTestingPlatformApplication>
<EnableMSTestRunner>true</EnableMSTestRunner>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
<!-- UI tests need a live desktop; never run them as part of MSBuild. -->
<RunVSTest>false</RunVSTest>
</PropertyGroup>
<!-- Stage the built test app under <Platform>\<Configuration>\tests\ so the UI-tests build
pipeline (CopyFiles glob **/<plat>/<config>/tests/**) picks it up, matching the other
*.UITests projects. Without this it builds to bin\ and is never staged into the artifact. -->
<PropertyGroup>
<OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\tests\Settings.UITests\</OutputPath>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MSTest" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\common\UITestAutomation.Next\UITestAutomation.Next.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,153 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Reflection;
using Microsoft.PowerToys.UITest.Next;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.Settings.UITests;
/// <summary>
/// Smoke test that drives the Settings shell via winappcli and asserts that clicking every
/// <c>NavigationViewItem</c> leaves the process alive.
/// </summary>
/// <remarks>
/// <para>
/// Inspired by <see href="https://github.com/microsoft/PowerToys/pull/48414"/>. Uses our
/// <see cref="UITestAutomation.Next"/> harness instead of the PR's bare wrapper so the same
/// surface (Find/Click/By/Element) works across all module tests.
/// </para>
/// <para>
/// Inherits <see cref="UITestBase"/> with <see cref="UITestBase.ReuseScopeAcrossTests"/> on, so a
/// single Settings window is reused across every nav-item case (one launch per class, not per test)
/// while still getting the framework's unified failure-media capture for free — no test-local
/// screenshot code. One method per nav item via <c>[DynamicData]</c> gives a discrete pass/fail per
/// item in Test Explorer / pipeline reports — if <c>FancyZonesNavItem</c> regresses, the report names it.
/// </para>
/// <para>
/// Selectors are AutomationIds straight from
/// <c>src/settings-ui/Settings.UI/SettingsXAML/Views/ShellPage.xaml</c>; they don't change with
/// the user's MUI language so the test stays localization-independent. Parent groups
/// (<c>SystemToolsNavItem</c>, <c>WindowingAndLayoutsNavItem</c>, <c>InputOutputNavItem</c>,
/// <c>FileManagementNavItem</c>, <c>AdvancedNavItem</c>) have <c>SelectsOnInvoked="False"</c>
/// and only expand on invoke; our <see cref="Element.Click"/> tries InvokePattern \u2192
/// TogglePattern \u2192 SelectionItemPattern \u2192 ExpandCollapsePattern in order so the same
/// call works for both navigation-y leaves and expand-y groups.
/// </para>
/// </remarks>
[TestClass]
public sealed class SettingsNavigationSmokeTests : UITestBase
{
// (ParentGroupSlug | null, NavItemSlug). Mirrors the live hierarchy in ShellPage.xaml.
// Footer items (OOBE/WhatIsNew/Feedback/Close) are intentionally excluded \u2014 those use
// Tapped handlers that open dialogs / external pages and aren't part of the in-shell
// navigation surface we're guarding against FailFast.
private static readonly NavigationCase[] NavigationItems = new[]
{
// Top-level
new NavigationCase(null, "DashboardNavItem"),
new NavigationCase(null, "GeneralNavItem"),
// System tools
new NavigationCase("SystemToolsNavItem", "AdvancedPasteNavItem"),
new NavigationCase("SystemToolsNavItem", "AwakeNavItem"),
new NavigationCase("SystemToolsNavItem", "CmdPalNavItem"),
new NavigationCase("SystemToolsNavItem", "ColorPickerNavItem"),
new NavigationCase("SystemToolsNavItem", "LightSwitchNavItem"),
new NavigationCase("SystemToolsNavItem", "PowerLauncherNavItem"),
new NavigationCase("SystemToolsNavItem", "ScreenRulerNavItem"),
new NavigationCase("SystemToolsNavItem", "ShortcutGuideNavItem"),
new NavigationCase("SystemToolsNavItem", "TextExtractorNavItem"),
new NavigationCase("SystemToolsNavItem", "ZoomItNavItem"),
// Windowing and layouts
new NavigationCase("WindowingAndLayoutsNavItem", "AlwaysOnTopNavItem"),
new NavigationCase("WindowingAndLayoutsNavItem", "CropAndLockNavItem"),
new NavigationCase("WindowingAndLayoutsNavItem", "FancyZonesNavItem"),
new NavigationCase("WindowingAndLayoutsNavItem", "GrabAndMoveNavItem"),
new NavigationCase("WindowingAndLayoutsNavItem", "WorkspacesNavItem"),
// Input / Output
new NavigationCase("InputOutputNavItem", "KeyboardManagerNavItem"),
new NavigationCase("InputOutputNavItem", "MouseUtilitiesNavItem"),
new NavigationCase("InputOutputNavItem", "MouseWithoutBordersNavItem"),
new NavigationCase("InputOutputNavItem", "PowerDisplayNavItem"),
new NavigationCase("InputOutputNavItem", "QuickAccentNavItem"),
// File management
new NavigationCase("FileManagementNavItem", "PowerPreviewNavItem"),
new NavigationCase("FileManagementNavItem", "FileLocksmithNavItem"),
new NavigationCase("FileManagementNavItem", "ImageResizerNavItem"),
new NavigationCase("FileManagementNavItem", "NewPlusNavItem"),
new NavigationCase("FileManagementNavItem", "PeekNavItem"),
new NavigationCase("FileManagementNavItem", "PowerRenameNavItem"),
// Advanced
new NavigationCase("AdvancedNavItem", "CmdNotFoundNavItem"),
new NavigationCase("AdvancedNavItem", "EnvironmentVariablesNavItem"),
new NavigationCase("AdvancedNavItem", "HostsNavItem"),
new NavigationCase("AdvancedNavItem", "RegistryPreviewNavItem"),
};
private const string ScopeProcessName = "PowerToys.Settings";
private const PowerToysModule Scope = PowerToysModule.PowerToysSettings;
public SettingsNavigationSmokeTests()
: base(Scope)
{
}
// Reuse one Settings window across all nav-item cases (no per-test relaunch); the framework
// still captures failure media per test and stops Settings once the class finishes.
protected override bool ReuseScopeAcrossTests => true;
public static IEnumerable<object[]> NavigationCases()
{
foreach (var c in NavigationItems)
{
yield return new object[] { c.ParentGroupSlug ?? string.Empty, c.NavItemSlug };
}
}
public static string GetNavCaseDisplayName(MethodInfo _, object[] data)
{
var parent = (string)data[0];
var item = (string)data[1];
return string.IsNullOrEmpty(parent) ? item : $"{parent} -> {item}";
}
[TestMethod]
[TestCategory("Settings")]
[TestCategory("winappcli-POC")]
[DynamicData(nameof(NavigationCases), DynamicDataDisplayName = nameof(GetNavCaseDisplayName))]
public void NavigationItem_NavigatesWithoutCrashing(string parentGroupSlug, string navItemSlug)
{
// The Settings window is shared across the class, so a parent group may already be expanded
// from a previous case. Only expand it when the child isn't already in the tree — clicking
// an already-expanded group would collapse it.
if (!string.IsNullOrEmpty(parentGroupSlug) && !Session.Has(By.AccessibilityId(navItemSlug), 500))
{
Find<NavigationViewItem>(By.AccessibilityId(parentGroupSlug)).Click();
}
// Child item is only in the visual tree once its parent is expanded; Find polls for up to
// timeoutMS so the expand animation doesn't race us.
Find<NavigationViewItem>(By.AccessibilityId(navItemSlug), timeoutMS: 5_000).Click();
// Brief settle so any unhandled exception in the page constructor or navigation handler
// has time to land in RoFailFast.
Thread.Sleep(250);
// Check by process name, not by launcher PID. Settings is single-instance: the EXE the
// framework started often exits cleanly after handing off to an existing instance, so the
// actual window may be owned by a different PID than the one we launched.
Assert.IsTrue(
SessionHelper.IsRunning(Scope),
$"No {ScopeProcessName} process remains after invoking '{navItemSlug}'. " +
"Likely a navigation FailFast regression \u2014 see ShellViewModel.Frame_NavigationFailed.");
}
private readonly record struct NavigationCase(string? ParentGroupSlug, string NavItemSlug);
}