diff --git a/.github/actions/spell-check/expect.txt b/.github/actions/spell-check/expect.txt
index add9fcce1812..69c28384b656 100644
--- a/.github/actions/spell-check/expect.txt
+++ b/.github/actions/spell-check/expect.txt
@@ -2084,6 +2084,8 @@ wifi
wikimedia
wikipedia
winapi
+winapp
+winappcli
winappsdk
windir
WINDOWCREATED
diff --git a/Cpp.Build.props b/Cpp.Build.props
index 48974c794ee2..1839a2aaf2da 100644
--- a/Cpp.Build.props
+++ b/Cpp.Build.props
@@ -66,7 +66,10 @@
stdcpplatestfalse
- _SILENCE_STDEXT_ARR_ITERS_DEPRECATION_WARNING;_UNICODE;UNICODE;%(PreprocessorDefinitions)
+
+ _SILENCE_STDEXT_ARR_ITERS_DEPRECATION_WARNING;_SILENCE_EXPERIMENTAL_COROUTINE_DEPRECATION_WARNINGS;_UNICODE;UNICODE;%(PreprocessorDefinitions)GuardProgramDatabase
diff --git a/PowerToys.slnx b/PowerToys.slnx
index a456003add84..af7894bf3d49 100644
--- a/PowerToys.slnx
+++ b/PowerToys.slnx
@@ -9,11 +9,11 @@
-
+
-
+
@@ -54,10 +54,14 @@
+
+
+
+
-
+
@@ -190,6 +194,10 @@
+
+
+
+
@@ -200,11 +208,11 @@
-
+
-
+
@@ -715,11 +723,11 @@
-
+
-
+
@@ -1088,6 +1096,10 @@
+
+
+
+
@@ -1119,14 +1131,14 @@
+
+
-
-
diff --git a/src/common/UITestAutomation.Next/By.cs b/src/common/UITestAutomation.Next/By.cs
new file mode 100644
index 000000000000..9ea1c97f60f7
--- /dev/null
+++ b/src/common/UITestAutomation.Next/By.cs
@@ -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;
+
+///
+/// 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 By.
+///
+public sealed class By
+{
+ public enum Kind
+ {
+ /// Plain-text search against Name or AutomationId (case-insensitive substring).
+ Text,
+
+ /// Stable AutomationId, when the developer set one.
+ AutomationId,
+
+ /// A semantic slug (e.g., btn-close-d1a0) printed by inspect/search.
+ Slug,
+ }
+
+ public Kind Selector { get; }
+
+ public string Value { get; }
+
+ private By(Kind kind, string value)
+ {
+ Selector = kind;
+ Value = value;
+ }
+
+ /// Plain-text search; what you'd type into winapp ui search "<text>".
+ public static By Name(string name) => new(Kind.Text, name);
+
+ /// Look up by stable AutomationId (winappcli also accepts these as selectors).
+ public static By AccessibilityId(string id) => new(Kind.AutomationId, id);
+
+ ///
+ public static By Id(string id) => new(Kind.AutomationId, id);
+
+ /// Direct slug selector (e.g., btn-colorpicker-b415) as printed by inspect/search.
+ public static By Slug(string slug) => new(Kind.Slug, slug);
+
+ public override string ToString() => $"{Selector}={Value}";
+}
diff --git a/src/common/UITestAutomation.Next/ClipboardHelper.cs b/src/common/UITestAutomation.Next/ClipboardHelper.cs
new file mode 100644
index 000000000000..6e1c252e2e47
--- /dev/null
+++ b/src/common/UITestAutomation.Next/ClipboardHelper.cs
@@ -0,0 +1,75 @@
+// 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;
+
+///
+/// Clipboard helpers that always execute on an STA thread (
+/// requires it). Tolerant — every method swallows clipboard errors and returns a default,
+/// so callers can use them in test finally blocks without worrying about masking
+/// the real failure.
+///
+public static class ClipboardHelper
+{
+ /// Return the current clipboard text, or if none / on error.
+ public static string GetText() => RunSTA(() => FormsClipboard.ContainsText() ? FormsClipboard.GetText() : string.Empty) ?? string.Empty;
+
+ /// Clear the clipboard. Returns true on success, false on error.
+ public static bool Clear() => RunSTA(() => { FormsClipboard.Clear(); return true; });
+
+ /// Set the clipboard text. Returns true on success, false on error.
+ public static bool SetText(string value) => RunSTA(() => { FormsClipboard.SetText(value); return true; });
+
+ ///
+ /// Poll the clipboard up to for the first non-empty text
+ /// different from . Returns 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.
+ ///
+ 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(Func body)
+ {
+ T? result = default;
+ try
+ {
+ var thread = new Thread(() =>
+ {
+ try
+ {
+ result = body();
+ }
+ catch
+ {
+ // Best effort — clipboard can throw under contention (OpenClipboard failures).
+ }
+ });
+ thread.SetApartmentState(ApartmentState.STA);
+ thread.Start();
+ thread.Join(TimeSpan.FromSeconds(5));
+ }
+ catch
+ {
+ }
+
+ return result;
+ }
+}
diff --git a/src/common/UITestAutomation.Next/Element/Button.cs b/src/common/UITestAutomation.Next/Element/Button.cs
new file mode 100644
index 000000000000..e3b827bfb1b4
--- /dev/null
+++ b/src/common/UITestAutomation.Next/Element/Button.cs
@@ -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";
+ }
+}
diff --git a/src/common/UITestAutomation.Next/Element/CheckBox.cs b/src/common/UITestAutomation.Next/Element/CheckBox.cs
new file mode 100644
index 000000000000..ec18aa244d31
--- /dev/null
+++ b/src/common/UITestAutomation.Next/Element/CheckBox.cs
@@ -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;
+
+///
+/// WinUI/WPF CheckBox (UIA ControlType CheckBox). State is read via
+/// winapp ui get-property ToggleState and changed via winapp ui invoke.
+///
+public class CheckBox : Element
+{
+ public CheckBox()
+ {
+ TargetControlType = "CheckBox";
+ }
+
+ /// True when UIA ToggleState is On (Indeterminate reads as not-checked).
+ public bool IsChecked => string.Equals(GetProperty("ToggleState"), "On", StringComparison.OrdinalIgnoreCase);
+
+ /// Flip to only if currently different.
+ public CheckBox SetCheck(bool value = true)
+ {
+ if (IsChecked != value)
+ {
+ Click();
+ }
+
+ return this;
+ }
+}
diff --git a/src/common/UITestAutomation.Next/Element/ComboBox.cs b/src/common/UITestAutomation.Next/Element/ComboBox.cs
new file mode 100644
index 000000000000..22bcae270047
--- /dev/null
+++ b/src/common/UITestAutomation.Next/Element/ComboBox.cs
@@ -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;
+
+///
+/// WinUI/WPF ComboBox (UIA ControlType ComboBox). Selection is driven CLI-first:
+/// expands via winapp ui invoke then clicks the chosen item, while
+/// editable combo boxes can be set directly with
+/// (winapp ui set-value).
+///
+///
+/// The dropdown items live in a popup that the owning process surfaces as a separate window
+/// (e.g. Settings' PopupHost). Process-scoped sessions ()
+/// see those items because every search re-resolves via -a; a window-scoped (-w)
+/// session may not, in which case prefer .
+///
+public class ComboBox : Element
+{
+ public ComboBox()
+ {
+ TargetControlType = "ComboBox";
+ }
+
+ /// Currently selected item text via winapp ui get-value (SelectionPattern fallback).
+ public string SelectedText => GetValue();
+
+ ///
+ /// Expand the combo box (CLI invoke toggles ExpandCollapse) and click the item whose
+ /// Name matches .
+ ///
+ public ComboBox Select(string itemName, int timeoutMS = 5000)
+ {
+ EnsureBound();
+ Click();
+ Thread.Sleep(150);
+ Owner!.Find(By.Name(itemName), timeoutMS).Click();
+ return this;
+ }
+
+ ///
+ /// Set the combo box value directly via winapp ui set-value (UIA ValuePattern). Works
+ /// for editable combo boxes; for non-editable combos use .
+ ///
+ public ComboBox SelectByText(string text)
+ {
+ EnsureBound();
+ WinappCli.InvokeAssertSuccess("ui", "set-value", Selector, text, Owner!.TargetFlag, Owner!.TargetValue);
+ return this;
+ }
+}
diff --git a/src/common/UITestAutomation.Next/Element/Custom.cs b/src/common/UITestAutomation.Next/Element/Custom.cs
new file mode 100644
index 000000000000..6180afffbbdc
--- /dev/null
+++ b/src/common/UITestAutomation.Next/Element/Custom.cs
@@ -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;
+
+///
+/// Custom control (UIA ControlType Custom) — used by bespoke surfaces like FancyZones
+/// zones and Workspaces canvases. Inherits drag from .
+///
+public class Custom : Element
+{
+ public Custom()
+ {
+ TargetControlType = "Custom";
+ }
+}
diff --git a/src/common/UITestAutomation.Next/Element/Element.cs b/src/common/UITestAutomation.Next/Element/Element.cs
new file mode 100644
index 000000000000..47c8ff043a0e
--- /dev/null
+++ b/src/common/UITestAutomation.Next/Element/Element.cs
@@ -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;
+
+/// Direction for (maps to winapp ui scroll --direction).
+public enum ScrollDirection
+{
+ Up,
+ Down,
+ Left,
+ Right,
+}
+
+///
+/// Reference to a UI element resolved via winappcli. Wraps the resolved
+/// (slug or text query), the owning , and the metadata captured at lookup
+/// time (control type, class name, name).
+///
+///
+/// Element instances are stateless on the wire — every property read and every action
+/// shells out to winapp ui …. The cached , ,
+/// and are the values seen at Find time; for fresh values, re-find.
+///
+public class Element
+{
+ internal Session? Owner { get; set; }
+
+ /// The selector winappcli will use to address this element (semantic slug, ID, or text query).
+ public string Selector { get; internal set; } = string.Empty;
+
+ /// Cached control type at lookup time (e.g. "Button", "ToggleSwitch").
+ public string ControlType { get; internal set; } = string.Empty;
+
+ /// Cached class name at lookup time (e.g. "ToggleSwitch", "TextBlock").
+ public string ClassName { get; internal set; } = string.Empty;
+
+ /// Cached Name property at lookup time.
+ public string Name { get; internal set; } = string.Empty;
+
+ /// Top-left X (screen pixels) reported by search at lookup time.
+ public int X { get; internal set; }
+
+ /// Top-left Y (screen pixels) reported by search at lookup time.
+ public int Y { get; internal set; }
+
+ /// Bounding-box width reported by search at lookup time.
+ public int Width { get; internal set; }
+
+ /// Bounding-box height reported by search at lookup time.
+ public int Height { get; internal set; }
+
+ /// UIA control type that this wrapper subclass expects (e.g. "Button"). Null = match anything.
+ protected string? TargetControlType { get; set; }
+
+ /// Optional ClassName filter applied alongside .
+ 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;
+ }
+
+ ///
+ /// Activate the element. winappcli's invoke tries InvokePattern → TogglePattern →
+ /// SelectionItemPattern → ExpandCollapsePattern in order; rightClick falls back to
+ /// click --right via real mouse input.
+ ///
+ 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);
+ }
+ }
+
+ ///
+ /// Mouse-simulation left-click via winapp ui click <slug>. 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.
+ ///
+ public void MouseClick(int msPostAction = 200)
+ {
+ EnsureBound();
+ WinappCli.InvokeAssertSuccess("ui", "click", Selector, Owner!.TargetFlag, Owner!.TargetValue);
+ if (msPostAction > 0)
+ {
+ Thread.Sleep(msPostAction);
+ }
+ }
+
+ ///
+ /// Double-click via winapp ui click <slug> --double (real mouse simulation). Use
+ /// for controls where a double-click has distinct behavior (list items, headers).
+ ///
+ public void DoubleClick(int msPostAction = 200)
+ {
+ EnsureBound();
+ WinappCli.InvokeAssertSuccess("ui", "click", Selector, Owner!.TargetFlag, Owner!.TargetValue, "--double");
+ if (msPostAction > 0)
+ {
+ Thread.Sleep(msPostAction);
+ }
+ }
+
+ /// Scroll this element into the visible area via winapp ui scroll-into-view.
+ public void ScrollIntoView()
+ {
+ EnsureBound();
+ WinappCli.InvokeAssertSuccess("ui", "scroll-into-view", Selector, Owner!.TargetFlag, Owner!.TargetValue);
+ }
+
+ ///
+ /// Scroll the element's nearest scrollable container in via
+ /// winapp ui scroll. If this element isn't scrollable, the CLI walks up to the nearest
+ /// scrollable ancestor.
+ ///
+ public void Scroll(ScrollDirection direction)
+ {
+ EnsureBound();
+ WinappCli.InvokeAssertSuccess(
+ "ui", "scroll", Selector,
+ Owner!.TargetFlag, Owner!.TargetValue,
+ "--direction", direction.ToString().ToLowerInvariant());
+ }
+
+ /// Jump the element's scrollable container to the top or bottom via winapp ui scroll --to.
+ public void ScrollToEdge(bool toBottom)
+ {
+ EnsureBound();
+ WinappCli.InvokeAssertSuccess(
+ "ui", "scroll", Selector,
+ Owner!.TargetFlag, Owner!.TargetValue,
+ "--to", toBottom ? "bottom" : "top");
+ }
+
+ ///
+ /// 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.
+ ///
+ public void Drag(int offsetX, int offsetY, int steps = 10)
+ {
+ EnsureBound();
+ var startX = X + (Width / 2);
+ var startY = Y + (Height / 2);
+ MouseHelper.Drag(startX, startY, startX + offsetX, startY + offsetY, steps);
+ }
+
+ /// Drag this element's center onto 's center (real mouse input).
+ public void DragTo(Element target, int steps = 10)
+ {
+ 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, steps);
+ }
+
+ ///
+ /// Hold down, drag this element's center to absolute screen
+ /// (, ), then release the key. Used for
+ /// modifier-drag scenarios (FancyZones merge, tab tear-off).
+ ///
+ public void KeyDownAndDrag(Key key, int targetX, int targetY, int steps = 10)
+ {
+ EnsureBound();
+ var startX = X + (Width / 2);
+ var startY = Y + (Height / 2);
+ KeyboardHelper.PressKey(key);
+ try
+ {
+ MouseHelper.Drag(startX, startY, targetX, targetY, steps);
+ }
+ finally
+ {
+ KeyboardHelper.ReleaseKey(key);
+ }
+ }
+
+ /// Move keyboard focus to this element.
+ public void Focus()
+ {
+ EnsureBound();
+ WinappCli.InvokeAssertSuccess("ui", "focus", Selector, Owner!.TargetFlag, Owner!.TargetValue);
+ }
+
+ ///
+ /// Read a single UIA property via winapp ui get-property … --json. Returns the raw string
+ /// value as winappcli reports it (e.g. "On"/"Off" for ToggleState).
+ ///
+ 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;
+ }
+
+ ///
+ /// UIA HelpText (from AutomationProperties.HelpText). Used by the Settings UI
+ /// ShortcutControl to surface the current shortcut as readable text on the EditButton
+ /// (e.g. "Win + Shift + C").
+ ///
+ public string HelpText => GetProperty("HelpText");
+
+ /// True when UIA reports the element as enabled (defaults to true when unknown).
+ public bool IsEnabled => ParseBool(GetProperty("IsEnabled"), defaultValue: true);
+
+ /// True when UIA reports the element off-screen (defaults to false when unknown).
+ public bool IsOffscreen => ParseBool(GetProperty("IsOffscreen"), defaultValue: false);
+
+ /// Convenience inverse of — mirrors the legacy harness's Displayed.
+ public bool Displayed => !IsOffscreen;
+
+ /// True when the element is selected (UIA SelectionItemPattern.IsSelected).
+ public bool Selected => ParseBool(GetProperty("IsSelected"), defaultValue: false);
+
+ /// The element's UIA AutomationId (empty when it has none).
+ public string AutomationId => GetProperty("AutomationId");
+
+ ///
+ /// Read any UIA property by name via winapp ui get-property. Alias of
+ /// kept for parity with the legacy harness's GetAttribute.
+ ///
+ public string GetAttribute(string attributeName) => GetProperty(attributeName);
+
+ ///
+ /// Read the element's value via winapp ui get-value … --json. winappcli walks
+ /// TextPattern → ValuePattern → SelectionPattern → Name to find a value, so this returns
+ /// the rendered text content of TextBlocks (e.g. ColorPicker's ColorTextBlock
+ /// where AutomationProperties.Name overrides the UIA Name with the color's friendly
+ /// name, but the actual Text binding holds the HEX value we want).
+ ///
+ 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;
+ }
+
+ ///
+ /// Wait for this element to reach on .
+ /// Mirrors winapp ui wait-for --property X --value Y -t T; returns true on success, false on timeout.
+ ///
+ 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;
+ }
+
+ ///
+ /// Wait for this element's value (smart fallback: TextPattern → ValuePattern →
+ /// SelectionPattern → Name) to match . When
+ /// is true, matches on substring instead of equality
+ /// (winapp ui wait-for … --value … --contains). Returns true on match, false on timeout.
+ ///
+ public bool WaitForValue(string expectedValue, bool contains = false, int timeoutMS = 5000)
+ {
+ EnsureBound();
+ var args = new List
+ {
+ "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;
+ }
+
+ ///
+ /// Wait for any element matching the original selector to disappear from the tree
+ /// (winapp ui wait-for … --gone).
+ ///
+ 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;
+ }
+
+ /// Find a descendant matching , scoped under this element via its slug.
+ public T Find(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(by, timeoutMS);
+ }
+
+ public T Find(string name, int timeoutMS = 5000)
+ where T : Element, new() => Find(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.");
+ }
+
+ /// Stringify a JSON property value regardless of kind (string / bool / number).
+ 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(),
+ };
+
+ /// Parse a winappcli boolean-ish property string; falls back to when empty.
+ 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";
+ }
+}
diff --git a/src/common/UITestAutomation.Next/Element/NavigationViewItem.cs b/src/common/UITestAutomation.Next/Element/NavigationViewItem.cs
new file mode 100644
index 000000000000..f769dfe58e93
--- /dev/null
+++ b/src/common/UITestAutomation.Next/Element/NavigationViewItem.cs
@@ -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;
+
+/// WinUI NavigationViewItem surfaces as ControlType.ListItem.
+public class NavigationViewItem : Element
+{
+ public NavigationViewItem()
+ {
+ TargetControlType = "ListItem";
+ }
+}
diff --git a/src/common/UITestAutomation.Next/Element/Pane.cs b/src/common/UITestAutomation.Next/Element/Pane.cs
new file mode 100644
index 000000000000..22584f0e785c
--- /dev/null
+++ b/src/common/UITestAutomation.Next/Element/Pane.cs
@@ -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;
+
+/// WinUI/WPF Pane (UIA ControlType Pane). Inherits drag from .
+public class Pane : Element
+{
+ public Pane()
+ {
+ TargetControlType = "Pane";
+ }
+}
diff --git a/src/common/UITestAutomation.Next/Element/RadioButton.cs b/src/common/UITestAutomation.Next/Element/RadioButton.cs
new file mode 100644
index 000000000000..1a345c298d58
--- /dev/null
+++ b/src/common/UITestAutomation.Next/Element/RadioButton.cs
@@ -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;
+
+///
+/// WinUI/WPF RadioButton (UIA ControlType RadioButton). Selected state is read via
+/// winapp ui get-property IsSelected; selection is performed via winapp ui invoke.
+///
+public class RadioButton : Element
+{
+ public RadioButton()
+ {
+ TargetControlType = "RadioButton";
+ }
+
+ /// True when this radio button is the selected option (UIA SelectionItemPattern.IsSelected).
+ public bool IsSelected => Selected;
+
+ /// Select this radio button if it isn't already selected.
+ public RadioButton Select()
+ {
+ if (!IsSelected)
+ {
+ Click();
+ }
+
+ return this;
+ }
+}
diff --git a/src/common/UITestAutomation.Next/Element/Slider.cs b/src/common/UITestAutomation.Next/Element/Slider.cs
new file mode 100644
index 000000000000..150d2cbc14bd
--- /dev/null
+++ b/src/common/UITestAutomation.Next/Element/Slider.cs
@@ -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;
+
+///
+/// WinUI/WPF Slider (UIA ControlType Slider). Reads and writes the value directly
+/// through the CLI (winapp ui get-value / set-value, RangeValuePattern) — no
+/// arrow-key stepping like the legacy harness.
+///
+public class Slider : Element
+{
+ public Slider()
+ {
+ TargetControlType = "Slider";
+ }
+
+ /// Current value via winapp ui get-value. Returns 0 when it can't be parsed.
+ public double Value
+ {
+ get
+ {
+ var raw = GetValue();
+ return double.TryParse(raw, NumberStyles.Any, CultureInfo.InvariantCulture, out var v) ? v : 0d;
+ }
+ }
+
+ /// Set the value directly via winapp ui set-value (RangeValuePattern).
+ public Slider SetValue(double value)
+ {
+ EnsureBound();
+ WinappCli.InvokeAssertSuccess(
+ "ui", "set-value", Selector,
+ value.ToString(CultureInfo.InvariantCulture),
+ Owner!.TargetFlag, Owner!.TargetValue);
+ return this;
+ }
+}
diff --git a/src/common/UITestAutomation.Next/Element/Tab.cs b/src/common/UITestAutomation.Next/Element/Tab.cs
new file mode 100644
index 000000000000..264288527e49
--- /dev/null
+++ b/src/common/UITestAutomation.Next/Element/Tab.cs
@@ -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;
+
+///
+/// Tab control (UIA ControlType Tab). Inherits drag from for
+/// tab-reorder / tear-off scenarios (see ).
+///
+public class Tab : Element
+{
+ public Tab()
+ {
+ TargetControlType = "Tab";
+ }
+}
diff --git a/src/common/UITestAutomation.Next/Element/TextBlock.cs b/src/common/UITestAutomation.Next/Element/TextBlock.cs
new file mode 100644
index 000000000000..a5ccb10c03e0
--- /dev/null
+++ b/src/common/UITestAutomation.Next/Element/TextBlock.cs
@@ -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;
+
+///
+/// Read-only text element (UIA ControlType Text, e.g. a WinUI TextBlock). The
+/// rendered text is read via winapp ui get-value, which falls back to the UIA Name.
+///
+public class TextBlock : Element
+{
+ public TextBlock()
+ {
+ TargetControlType = "Text";
+ }
+
+ /// The displayed text via winapp ui get-value.
+ public string Text => GetValue();
+}
diff --git a/src/common/UITestAutomation.Next/Element/TextBox.cs b/src/common/UITestAutomation.Next/Element/TextBox.cs
new file mode 100644
index 000000000000..1b719c95032f
--- /dev/null
+++ b/src/common/UITestAutomation.Next/Element/TextBox.cs
@@ -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;
+
+/// Edit / TextBox control. Drives via winapp ui set-value and get-value.
+public class TextBox : Element
+{
+ public TextBox()
+ {
+ TargetControlType = "Edit";
+ }
+
+ /// Set the textbox content via winappcli's set-value (UIA ValuePattern).
+ public TextBox SetText(string value)
+ {
+ EnsureBound();
+ WinappCli.InvokeAssertSuccess("ui", "set-value", Selector, value, Owner!.TargetFlag, Owner!.TargetValue);
+ return this;
+ }
+
+ /// Current text content via winapp ui get-value.
+ 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;
+ }
+ }
+ }
+}
diff --git a/src/common/UITestAutomation.Next/Element/Thumb.cs b/src/common/UITestAutomation.Next/Element/Thumb.cs
new file mode 100644
index 000000000000..8eb3d36fde62
--- /dev/null
+++ b/src/common/UITestAutomation.Next/Element/Thumb.cs
@@ -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;
+
+///
+/// Resize/move Thumb (UIA ControlType Thumb), e.g. a splitter or slider handle.
+/// Inherits drag from .
+///
+public class Thumb : Element
+{
+ public Thumb()
+ {
+ TargetControlType = "Thumb";
+ }
+}
diff --git a/src/common/UITestAutomation.Next/Element/ToggleSwitch.cs b/src/common/UITestAutomation.Next/Element/ToggleSwitch.cs
new file mode 100644
index 000000000000..a48fe0d7d7a0
--- /dev/null
+++ b/src/common/UITestAutomation.Next/Element/ToggleSwitch.cs
@@ -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;
+
+///
+/// WinUI ToggleSwitch surfaces as ControlType.Button + ClassName="ToggleSwitch".
+/// Pinning avoids picking up sibling Buttons with the same Name
+/// (e.g. the module's navigation card on the dashboard).
+///
+public class ToggleSwitch : Button
+{
+ public ToggleSwitch()
+ {
+ TargetClassName = "ToggleSwitch";
+ }
+
+ /// Reads UIA ToggleState via winappcli and compares to "On".
+ public bool IsOn => string.Equals(GetProperty("ToggleState"), "On", StringComparison.OrdinalIgnoreCase);
+
+ /// Flip to only if currently different.
+ public ToggleSwitch Toggle(bool value = true)
+ {
+ if (IsOn != value)
+ {
+ Click();
+ }
+
+ return this;
+ }
+}
diff --git a/src/common/UITestAutomation.Next/Element/Window.cs b/src/common/UITestAutomation.Next/Element/Window.cs
new file mode 100644
index 000000000000..bd5c336c26ed
--- /dev/null
+++ b/src/common/UITestAutomation.Next/Element/Window.cs
@@ -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";
+ }
+}
diff --git a/src/common/UITestAutomation.Next/ElevationHelper.cs b/src/common/UITestAutomation.Next/ElevationHelper.cs
new file mode 100644
index 000000000000..017954f47b1a
--- /dev/null
+++ b/src/common/UITestAutomation.Next/ElevationHelper.cs
@@ -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;
+
+///
+/// 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.
+///
+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);
+
+ /// True when the current test-host process is elevated.
+ public static bool IsCurrentProcessElevated()
+ {
+ using var p = Process.GetCurrentProcess();
+ return IsHandleElevated(p.Handle);
+ }
+
+ /// True when process is elevated; null if it can't be queried.
+ 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);
+ }
+ }
+}
diff --git a/src/common/UITestAutomation.Next/EnvironmentConfig.cs b/src/common/UITestAutomation.Next/EnvironmentConfig.cs
new file mode 100644
index 000000000000..e42a5dfc98bd
--- /dev/null
+++ b/src/common/UITestAutomation.Next/EnvironmentConfig.cs
@@ -0,0 +1,35 @@
+// Copyright (c) Microsoft Corporation
+// The Microsoft Corporation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+namespace Microsoft.PowerToys.UITest.Next;
+
+///
+/// Centralized access to the environment variables that influence UI-test execution. Mirrors the
+/// legacy harness's EnvironmentConfig so module tests can branch on pipeline-vs-local and
+/// installed-build-vs-dev-build the same way.
+///
+public static class EnvironmentConfig
+{
+ private static readonly Lazy InPipeline = new(() =>
+ !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("platform")));
+
+ private static readonly Lazy 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 PlatformValue = new(() =>
+ Environment.GetEnvironmentVariable("platform"));
+
+ /// True when running in CI/CD (the platform env var is set).
+ public static bool IsInPipeline => InPipeline.Value;
+
+ /// True when tests should target the installed PowerToys build (useInstallerForTest).
+ public static bool UseInstallerForTest => UseInstaller.Value;
+
+ /// Build platform from the platform env var (e.g. x64, arm64), or null locally.
+ public static string? Platform => PlatformValue.Value;
+}
diff --git a/src/common/UITestAutomation.Next/FRAMEWORK-PARITY-PLAN.md b/src/common/UITestAutomation.Next/FRAMEWORK-PARITY-PLAN.md
new file mode 100644
index 000000000000..7fa248d49800
--- /dev/null
+++ b/src/common/UITestAutomation.Next/FRAMEWORK-PARITY-PLAN.md
@@ -0,0 +1,113 @@
+# 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. Nothing
+here is implemented yet — this is the backlog to work through later.
+
+> 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`
+
+## 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()`.
+
+Everything below is present in the legacy harness but **missing or unwired** in `.Next`.
+
+---
+
+## 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.
+
+## 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`.
+
+## 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.
+
+## 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`.
+
+## 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 FFmpeg dependency decision |
+| 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
+separate, optional sub-task (it's the heaviest dependency).
+
+## 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`.
+
+---
+
+## 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 last.
+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.
diff --git a/src/common/UITestAutomation.Next/KeyboardHelper.cs b/src/common/UITestAutomation.Next/KeyboardHelper.cs
new file mode 100644
index 000000000000..9c7120fadb38
--- /dev/null
+++ b/src/common/UITestAutomation.Next/KeyboardHelper.cs
@@ -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;
+
+/// Virtual-key constants used by .
+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,
+}
+
+///
+/// Global keyboard input. Uses the same hybrid strategy as the legacy harness because pure
+/// keybd_event injection doesn't reliably trigger RegisterHotKey-registered global
+/// hotkeys for the PowerToys runner: hold LWIN down via keybd_event, then send the
+/// remaining chord via which uses
+/// SendInput with proper modifier tracking, then release LWIN.
+///
+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;
+
+ ///
+ /// Send a chord of keys. If the chord contains , LWIN is held via
+ /// keybd_event while the remaining keys are sent via .
+ /// Otherwise everything goes through SendKeys.SendWait (the modifier-aware Windows path).
+ ///
+ 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);
+ }
+ }
+ }
+
+ /// Press (and hold) a key via keybd_event. Pair with .
+ public static void PressKey(Key key) =>
+ keybd_event((byte)key, 0, IsExtended(key) ? KEYEVENTF_EXTENDEDKEY : 0u, UIntPtr.Zero);
+
+ /// Release a key previously pressed with .
+ public static void ReleaseKey(Key key) =>
+ keybd_event((byte)key, 0, KEYEVENTF_KEYUP | (IsExtended(key) ? KEYEVENTF_EXTENDEDKEY : 0u), UIntPtr.Zero);
+
+ /// Press + release a single key.
+ public static void SendKey(Key key)
+ {
+ PressKey(key);
+ Thread.Sleep(20);
+ ReleaseKey(key);
+ }
+
+ /// Press + release each key in order (independent taps, not a held chord).
+ 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;
+}
diff --git a/src/common/UITestAutomation.Next/ModuleConfigData.cs b/src/common/UITestAutomation.Next/ModuleConfigData.cs
new file mode 100644
index 000000000000..ba91567e8478
--- /dev/null
+++ b/src/common/UITestAutomation.Next/ModuleConfigData.cs
@@ -0,0 +1,165 @@
+// 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;
+
+///
+/// Modules of PowerToys that a can target.
+///
+public enum PowerToysModule
+{
+ PowerToysSettings,
+ Runner,
+ ColorPicker,
+ FancyZonesEditor,
+ Hosts,
+ Workspaces,
+ PowerRename,
+ CommandPalette,
+ ScreenRuler,
+ LightSwitch,
+}
+
+///
+/// Resolves executable paths, process names, and window titles for a .
+///
+///
+/// Path resolution order: an explicit POWERTOYS_INSTALL_DIR override, then the installed
+/// build (Program Files / LocalAppData), then the repo's dev-build output
+/// (<root>\<plat>\<cfg>). Setting useInstallerForTest forces the installed
+/// layout. This lets the same tests run against an installed PowerToys (CI) or a local dev build.
+///
+internal static class ModulePaths
+{
+ private sealed record ModuleMeta(string ExeName, string? SubDir, string ProcessName, string WindowTitle);
+
+ private static readonly IReadOnlyDictionary Meta =
+ new Dictionary
+ {
+ [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 InstalledRoot = new(ResolveInstalledRoot);
+ private static readonly Lazy RepoRoot = new(FindRepoRoot);
+
+ public static string ExePathFor(PowerToysModule module)
+ {
+ var meta = Meta[module];
+
+ 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);
+
+ if (EnvironmentConfig.UseInstallerForTest)
+ {
+ return installed;
+ }
+
+ if (File.Exists(installed))
+ {
+ return installed;
+ }
+
+ if (TryComposeDevBuild(meta, out var dev))
+ {
+ return dev;
+ }
+
+ return installed;
+ }
+
+ /// Process name as winappcli's -a flag accepts it (case-insensitive substring).
+ public static string ProcessNameFor(PowerToysModule module) => Meta[module].ProcessName;
+
+ /// Expected window title substring; used to pick the right HWND when a module has several windows.
+ 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;
+ var root = RepoRoot.Value;
+ if (string.IsNullOrEmpty(root))
+ {
+ return false;
+ }
+
+ 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;
+ }
+}
diff --git a/src/common/UITestAutomation.Next/MonitorInfo.cs b/src/common/UITestAutomation.Next/MonitorInfo.cs
new file mode 100644
index 000000000000..a91445ed53c4
--- /dev/null
+++ b/src/common/UITestAutomation.Next/MonitorInfo.cs
@@ -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;
+
+///
+/// Multi-monitor enumeration via Win32 (EnumDisplayMonitors / GetMonitorInfo).
+/// winappcli exposes no display topology, so this stays native — useful for multi-monitor
+/// utilities (FancyZones, Mouse Utilities, Mouse Without Borders).
+///
+public static class MonitorInfo
+{
+ /// One physical display, in virtual-screen pixel coordinates.
+ public sealed record Monitor(
+ string DeviceName,
+ int Left,
+ int Top,
+ int Right,
+ int Bottom,
+ int WorkLeft,
+ int WorkTop,
+ int WorkRight,
+ int WorkBottom,
+ bool IsPrimary)
+ {
+ /// Full monitor width in pixels.
+ public int Width => Right - Left;
+
+ /// Full monitor height in pixels.
+ 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);
+
+ /// All connected displays, in enumeration order.
+ public static IReadOnlyList GetAll()
+ {
+ var list = new List();
+
+ 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() };
+ 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;
+ }
+ }
+
+ /// The primary display, or null if none reported.
+ public static Monitor? GetPrimary() => GetAll().FirstOrDefault(m => m.IsPrimary);
+
+ /// Number of connected displays.
+ 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;
+ }
+}
diff --git a/src/common/UITestAutomation.Next/MouseHelper.cs b/src/common/UITestAutomation.Next/MouseHelper.cs
new file mode 100644
index 000000000000..e1fa975b1aa3
--- /dev/null
+++ b/src/common/UITestAutomation.Next/MouseHelper.cs
@@ -0,0 +1,152 @@
+// 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;
+
+///
+/// Global mouse input via Win32 SetCursorPos and mouse_event. Required for
+/// scenarios like clicking inside the ColorPicker overlay, which is a transparent window that
+/// can't be targeted via UIA / winapp ui click.
+///
+public static class MouseHelper
+{
+ [StructLayout(LayoutKind.Sequential)]
+ private struct POINT
+ {
+ public int X;
+ public int Y;
+ }
+
+ 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 = 60;
+ 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")]
+#pragma warning disable SA1300 // win32 API name
+ private static extern void mouse_event(uint dwFlags, uint dx, uint dy, uint dwData, UIntPtr dwExtraInfo);
+#pragma warning restore SA1300
+
+ /// Move the OS cursor to absolute screen coordinates.
+ public static void MoveTo(int x, int y) => SetCursorPos(x, y);
+
+ /// Current cursor position in screen pixels.
+ public static (int X, int Y) GetMousePosition()
+ {
+ GetCursorPos(out var p);
+ return (p.X, p.Y);
+ }
+
+ /// Press the left mouse button down at the current position.
+ public static void LeftDown() => mouse_event(MOUSEEVENTF_LEFTDOWN, 0, 0, 0, UIntPtr.Zero);
+
+ /// Release the left mouse button.
+ public static void LeftUp() => mouse_event(MOUSEEVENTF_LEFTUP, 0, 0, 0, UIntPtr.Zero);
+
+ /// Press the right mouse button down at the current position.
+ public static void RightDown() => mouse_event(MOUSEEVENTF_RIGHTDOWN, 0, 0, 0, UIntPtr.Zero);
+
+ /// Release the right mouse button.
+ public static void RightUp() => mouse_event(MOUSEEVENTF_RIGHTUP, 0, 0, 0, UIntPtr.Zero);
+
+ /// Press the middle mouse button down at the current position.
+ public static void MiddleDown() => mouse_event(MOUSEEVENTF_MIDDLEDOWN, 0, 0, 0, UIntPtr.Zero);
+
+ /// Release the middle mouse button.
+ public static void MiddleUp() => mouse_event(MOUSEEVENTF_MIDDLEUP, 0, 0, 0, UIntPtr.Zero);
+
+ /// Press + release left mouse button at the current cursor position.
+ public static void LeftClick()
+ {
+ LeftDown();
+ Thread.Sleep(ClickDelayMs);
+ LeftUp();
+ }
+
+ /// Move cursor to (x,y) and left-click.
+ public static void LeftClickAt(int x, int y)
+ {
+ MoveTo(x, y);
+ Thread.Sleep(40);
+ LeftClick();
+ }
+
+ /// Press + release right mouse button at the current cursor position.
+ public static void RightClick()
+ {
+ RightDown();
+ Thread.Sleep(ClickDelayMs);
+ RightUp();
+ }
+
+ /// Press + release middle mouse button at the current cursor position.
+ public static void MiddleClick()
+ {
+ MiddleDown();
+ Thread.Sleep(ClickDelayMs);
+ MiddleUp();
+ }
+
+ /// Left double-click at the current cursor position.
+ public static void DoubleClick()
+ {
+ LeftClick();
+ Thread.Sleep(ClickDelayMs);
+ LeftClick();
+ }
+
+ /// Scroll the wheel by a raw amount (positive = up, negative = down; one tick = 120).
+ public static void ScrollWheel(int amount) => mouse_event(MOUSEEVENTF_WHEEL, 0, 0, (uint)amount, UIntPtr.Zero);
+
+ /// Scroll the wheel up by one tick.
+ public static void ScrollUp() => ScrollWheel(WheelTick);
+
+ /// Scroll the wheel down by one tick.
+ public static void ScrollDown() => ScrollWheel(-WheelTick);
+
+ ///
+ /// 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 winapp ui search bounds).
+ ///
+ public static void Drag(int fromX, int fromY, int toX, int toY, int steps = 10)
+ {
+ if (steps < 1)
+ {
+ steps = 1;
+ }
+
+ MoveTo(fromX, fromY);
+ Thread.Sleep(40);
+ LeftDown();
+ Thread.Sleep(40);
+
+ var dx = (double)(toX - fromX) / steps;
+ var dy = (double)(toY - fromY) / steps;
+ for (var i = 1; i <= steps; i++)
+ {
+ MoveTo(fromX + (int)Math.Round(dx * i), fromY + (int)Math.Round(dy * i));
+ Thread.Sleep(15);
+ }
+
+ MoveTo(toX, toY);
+ Thread.Sleep(40);
+ LeftUp();
+ }
+}
diff --git a/src/common/UITestAutomation.Next/Session.cs b/src/common/UITestAutomation.Next/Session.cs
new file mode 100644
index 000000000000..52ad9cd51f53
--- /dev/null
+++ b/src/common/UITestAutomation.Next/Session.cs
@@ -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;
+
+///
+/// A test session bound to either a specific window (HWND) or a whole process (name or PID).
+/// All / calls route to winapp ui search
+/// scoped by /.
+///
+///
+/// Two scopes are supported:
+///
+/// Window (-w <hwnd>) — 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).
+/// Process (-a <name|pid>) — simpler when the target
+/// process owns exactly one user-facing window. Built via . Matches
+/// the pattern in .
+///
+///
+public sealed class Session
+{
+ public enum TargetScope
+ {
+ /// Scope all CLI calls to a specific HWND via -w.
+ Window,
+
+ /// Scope all CLI calls to a process (name substring or PID) via -a.
+ Process,
+ }
+
+ /// Decimal HWND of the target window, or 0 when bound by .
+ public long WindowHandle { get; }
+
+ /// String form of for passing to winappcli's -w flag.
+ public string WindowHandleArg { get; }
+
+ /// The scope these calls run against (window or process).
+ public TargetScope Scope { get; }
+
+ /// winappcli flag for the active scope (-w or -a).
+ public string TargetFlag { get; }
+
+ /// Value to pass after — the decimal HWND or the process name/PID.
+ public string TargetValue { get; }
+
+ public string WindowTitle { get; }
+
+ public int ProcessId { get; }
+
+ public string ProcessName { get; }
+
+ public PowerToysModule InitScope { get; }
+
+ /// True when the target process is elevated; null when unknown (no PID captured).
+ 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;
+ }
+
+ ///
+ /// Build a session scoped to a whole process via winapp ... -a <app>. 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 -a, so window-replacement during the test
+ /// (re-navigation, page swap) is handled transparently.
+ ///
+ /// Process name substring (e.g. "PowerToys.Settings") or PID as a string.
+ /// Module label used for diagnostics only.
+ /// How long to wait for the process to expose at least one UIA window.
+ 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!;
+ }
+
+ ///
+ /// Attach to a running module's first window (window-scoped, so it carries a HWND) and
+ /// optionally resize it to a preset . Useful when a test needs a
+ /// deterministic window size or wants to drive an already-running module.
+ ///
+ 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(By by, int timeoutMS = 5000)
+ where T : Element, new() => FindUnder(by, timeoutMS);
+
+ public T Find(string name, int timeoutMS = 5000)
+ where T : Element, new() => FindUnder(By.Name(name), timeoutMS);
+
+ public Element Find(By by, int timeoutMS = 5000) => FindUnder(by, timeoutMS);
+
+ public Element Find(string name, int timeoutMS = 5000) => FindUnder(By.Name(name), timeoutMS);
+
+ public bool Has(By by, int timeoutMS = 1000)
+ where T : Element, new() => FindAll(by, timeoutMS).Count >= 1;
+
+ public bool Has(By by, int timeoutMS = 1000) => Has(by, timeoutMS);
+
+ public bool Has(string name, int timeoutMS = 1000) => Has(By.Name(name), timeoutMS);
+
+ public bool HasOne(By by, int timeoutMS = 1000)
+ where T : Element, new() => FindAll(by, timeoutMS).Count == 1;
+
+ ///
+ /// All elements matching on this session's window, optionally polling
+ /// for up to if none are present initially.
+ ///
+ public ReadOnlyCollection FindAll(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(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(typed);
+ }
+
+ Thread.Sleep(100);
+ }
+ }
+
+ internal T FindUnder(By by, int timeoutMS)
+ where T : Element, new()
+ {
+ var collection = FindAll(by, timeoutMS);
+ Assert.IsTrue(collection.Count > 0, $"UI-Element({typeof(T).Name}) not found using selector: {by}");
+ return collection[0];
+ }
+
+ ///
+ /// Generic polling helper, equivalent to winappcli's wait-for --value but evaluated in C#
+ /// so the predicate can read multiple properties / compose conditions.
+ ///
+ public bool WaitFor(Func 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;
+ }
+
+ ///
+ /// Wait for an element matching to appear in the tree via
+ /// winapp ui wait-for. Returns true if it appeared within .
+ ///
+ 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;
+ }
+
+ ///
+ /// Capture a PNG of the session's target via winapp ui screenshot. Pass an
+ /// to crop to that element's bounds, or set
+ /// to grab from the screen (includes popups / overlays /
+ /// flyouts that PrintWindow misses).
+ ///
+ public string Screenshot(string outputPath, Element? element = null, bool captureScreen = false)
+ {
+ WinappCli.InvokeAssertSuccess(BuildScreenshotArgs(outputPath, element, captureScreen));
+ return outputPath;
+ }
+
+ /// Non-asserting screenshot for cleanup / failure-artifact paths. Returns false on error.
+ 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 { "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();
+ }
+
+ ///
+ /// Dump the UIA tree for this session's target via winapp ui inspect --json.
+ /// Returned shape: { "windows": [{ "elements": [{ "type", "name", "value", "children": [...] }] }] }.
+ ///
+ /// Tree depth (ignored by winappcli when is set).
+ /// Only invokable elements (auto-depth), as a flat list.
+ /// Omit disabled elements.
+ /// Omit off-screen elements.
+ public JsonElement Inspect(int depth = 6, bool interactive = false, bool hideDisabled = false, bool hideOffscreen = false)
+ {
+ var args = new List
+ {
+ "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());
+ }
+
+ ///
+ /// Walk the ancestor chain from up to the root via
+ /// winapp ui inspect --ancestors.
+ ///
+ public JsonElement InspectAncestors(Element element) =>
+ WinappCli.InvokeJson("ui", "inspect", "--ancestors", element.Selector, TargetFlag, TargetValue, "--json");
+
+ /// The element that currently has keyboard focus, via winapp ui get-focused --json.
+ public JsonElement GetFocused() => WinappCli.InvokeJson("ui", "get-focused", TargetFlag, TargetValue, "--json");
+
+ ///
+ /// Convenience reader for the focused element's Name (empty if none / unknown). Useful for
+ /// keyboard-navigation assertions.
+ ///
+ 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;
+ }
+
+ /// Connection / target info for diagnostics via winapp ui status --json.
+ public JsonElement Status() => WinappCli.InvokeJson("ui", "status", TargetFlag, TargetValue, "--json");
+
+ /// Send keystrokes via Win32 keybd_event. Required for global PowerToys hotkeys.
+ public void SendKeys(params Key[] keys) => KeyboardHelper.SendKeys(keys);
+
+ public void Cleanup()
+ {
+ // Stateless — nothing to release on the wire.
+ }
+
+ private List 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();
+ 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);
+}
diff --git a/src/common/UITestAutomation.Next/SessionHelper.cs b/src/common/UITestAutomation.Next/SessionHelper.cs
new file mode 100644
index 000000000000..09a78f445d19
--- /dev/null
+++ b/src/common/UITestAutomation.Next/SessionHelper.cs
@@ -0,0 +1,250 @@
+// 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;
+
+///
+/// Owns process launch + window resolution for a . Equivalent to
+/// the old SessionHelper but the engine is winappcli — no WinAppDriver, no Appium.
+///
+///
+///
+/// Two consumption shapes:
+///
+/// Per-test (HWND-scoped): construct + call .
+/// does this in [TestInitialize].
+/// Class-scoped or process-scoped: the static helpers (,
+/// , ) let a smoke-test [ClassInitialize]
+/// reuse the launch+wait flow without taking on a HWND binding.
+///
+///
+///
+public sealed class SessionHelper
+{
+ private readonly PowerToysModule scope;
+
+ public SessionHelper(PowerToysModule scope)
+ {
+ this.scope = scope;
+ }
+
+ public Session Init()
+ {
+ EnsureRunning(scope, TimeSpan.FromSeconds(20));
+
+ var window = WaitForMainWindow(scope, TimeSpan.FromSeconds(20));
+ Assert.IsNotNull(window, $"Main window for {scope} did not appear via winappcli within 20s");
+ return window!;
+ }
+
+ /// Process name as winappcli's -a flag (and ) accept it.
+ public static string GetProcessName(PowerToysModule scope) => ModulePaths.ProcessNameFor(scope);
+
+ /// Returns true if at least one process matching is running.
+ public static bool IsRunning(PowerToysModule scope) =>
+ Process.GetProcessesByName(GetProcessName(scope)).Length > 0;
+
+ ///
+ /// Ensure the runner-owned environment for is up and has presented a
+ /// UIA-visible window. Returns false when the target was already running (nothing
+ /// launched), true when a launch was needed — callers track this so cleanup only kills
+ /// what the test itself started.
+ ///
+ ///
+ ///
+ /// The PowerToys runner (PowerToys.exe) 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. PowerToys.ColorPickerUI.exe) 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 scope we launch
+ /// PowerToys.exe --open-settings: the runner starts (or, being single-instance, the
+ /// already-running one is signalled) and presents the Settings window.
+ ///
+ ///
+ /// UseShellExecute = true is intentional: with UseShellExecute = false 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.
+ ///
+ ///
+ /// 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.
+ ///
+ ///
+ public static bool EnsureRunning(PowerToysModule scope, TimeSpan timeout)
+ {
+ var processName = GetProcessName(scope);
+
+ if (IsRunning(scope))
+ {
+ WaitForAnyWindow(processName, timeout);
+ return false;
+ }
+
+ // The Settings UI is owned by the runner — open it through PowerToys.exe rather than
+ // launching PowerToys.Settings.exe standalone (see ). This is what gives the
+ // runner ownership of module toggles and activation hotkeys during the test.
+ if (scope == PowerToysModule.PowerToysSettings)
+ {
+ LaunchViaShell(ModulePaths.ExePathFor(PowerToysModule.Runner), "--open-settings");
+ WaitForAnyWindow(processName, timeout);
+ return true;
+ }
+
+ // Runner scope (and modules that legitimately run standalone) launch their own exe.
+ LaunchViaShell(ModulePaths.ExePathFor(scope), null);
+ WaitForAnyWindow(processName, timeout);
+ return true;
+ }
+
+ ///
+ /// Launch detached via ShellExecute (see
+ /// remarks for why UseShellExecute = true is required). The launcher PID is discarded;
+ /// readiness is judged by window presence, not the process handle.
+ ///
+ 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 .
+ }
+ }
+ catch (Exception ex)
+ {
+ Assert.Fail($"Failed to launch '{exe} {arguments}': {ex.Message}");
+ }
+ }
+
+ ///
+ /// 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.
+ ///
+ 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);
+ }
+
+ private static void WaitForAnyWindow(string processName, TimeSpan timeout)
+ {
+ var deadline = DateTime.UtcNow + timeout;
+ while (DateTime.UtcNow < deadline)
+ {
+ if (WindowsFinder.ListByApp(processName).Count > 0)
+ {
+ // Give XAML a moment to populate the visual tree.
+ Thread.Sleep(750);
+ return;
+ }
+
+ Thread.Sleep(250);
+ }
+
+ Assert.Fail(
+ $"No UIA-visible window from process '{processName}' appeared within {timeout.TotalSeconds}s.");
+ }
+
+ ///
+ /// Poll winapp ui list-windows --json until a window matching the target module appears.
+ /// Returns a bound to its HWND.
+ ///
+ ///
+ /// When the same process owns multiple windows (Settings exe also owns the PopupHost
+ /// 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.
+ ///
+ 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;
+ }
+}
diff --git a/src/common/UITestAutomation.Next/SettingsConfigHelper.cs b/src/common/UITestAutomation.Next/SettingsConfigHelper.cs
new file mode 100644
index 000000000000..3169dfd76ef0
--- /dev/null
+++ b/src/common/UITestAutomation.Next/SettingsConfigHelper.cs
@@ -0,0 +1,105 @@
+// Copyright (c) Microsoft Corporation
+// The Microsoft Corporation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace Microsoft.PowerToys.UITest.Next;
+
+///
+/// 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 Settings.UI.Library.
+///
+public static class SettingsConfigHelper
+{
+ private static readonly JsonSerializerOptions Indented = new() { WriteIndented = true };
+
+ /// Root of the per-user PowerToys settings: %LocalAppData%\Microsoft\PowerToys.
+ public static string PowerToysSettingsRoot => Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "Microsoft",
+ "PowerToys");
+
+ private static string GlobalSettingsPath => Path.Combine(PowerToysSettingsRoot, "settings.json");
+
+ ///
+ /// Enable exactly the named modules in the global settings.json and disable every other
+ /// module already listed. Module names are the keys under "enabled" (e.g. "FancyZones",
+ /// "ColorPicker", "Peek"). Creates the file and keys when missing.
+ ///
+ public static void ConfigureGlobalModuleSettings(params string[]? modulesToEnable)
+ {
+ modulesToEnable ??= Array.Empty();
+ 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));
+ }
+
+ ///
+ /// Update a module's settings.json
+ /// (%LocalAppData%\Microsoft\PowerToys\<module>\settings.json). Seeds the file from
+ /// when it doesn't exist, then applies
+ /// to the parsed object and writes it back.
+ ///
+ public static void UpdateModuleSettings(
+ string moduleName,
+ string defaultSettingsContent,
+ Action 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));
+ }
+}
diff --git a/src/common/UITestAutomation.Next/UITestAutomation.Next.csproj b/src/common/UITestAutomation.Next/UITestAutomation.Next.csproj
new file mode 100644
index 000000000000..76936eefd76b
--- /dev/null
+++ b/src/common/UITestAutomation.Next/UITestAutomation.Next.csproj
@@ -0,0 +1,25 @@
+
+
+ Library
+ net10.0-windows10.0.26100.0
+ enable
+ enable
+
+ true
+ false
+ Microsoft.PowerToys.UITest.Next
+ Microsoft.PowerToys.UITest.Next
+
+
+
+
+
+
+
diff --git a/src/common/UITestAutomation.Next/UITestBase.cs b/src/common/UITestAutomation.Next/UITestBase.cs
new file mode 100644
index 000000000000..7e2298cdf242
--- /dev/null
+++ b/src/common/UITestAutomation.Next/UITestBase.cs
@@ -0,0 +1,122 @@
+// 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;
+
+///
+/// Base class for the next-generation PowerToys UI tests. Engine is winappcli — every UI call
+/// shells out to winapp.exe. No WinAppDriver, no Selenium, no third-party NuGet packages.
+///
+///
+///
+/// Drop-in shape replacement for the existing Microsoft.PowerToys.UITest.UITestBase:
+/// inherit, pass a , and use Session / Find<T> in tests.
+///
+///
+/// Test Explorer integration is automatic — MSTest's [TestClass] / [TestInitialize] /
+/// [TestCleanup] plus the Microsoft.Testing.Platform runner (enabled repo-wide in
+/// Directory.Build.props) are everything Test Explorer and dotnet test need.
+///
+///
+[TestClass]
+public class UITestBase : IDisposable
+{
+ ///
+ /// Lazy one-shot probe for winapp.exe. Runs the first time any UITest in the
+ /// process initializes — the cost is one extra winapp --version call per test run.
+ ///
+ private static readonly Lazy CliAvailable = new(WinappCli.IsAvailable);
+
+ private readonly PowerToysModule scope;
+ private SessionHelper? sessionHelper;
+ private bool disposed;
+
+ public required TestContext TestContext { get; set; }
+
+ public Session Session { get; private set; } = null!;
+
+ protected UITestBase(PowerToysModule scope = PowerToysModule.PowerToysSettings)
+ {
+ this.scope = scope;
+ }
+
+ [TestInitialize]
+ public void TestInit()
+ {
+ if (!CliAvailable.Value)
+ {
+ Assert.Fail(WinappCli.InstallHint);
+ }
+
+ sessionHelper = new SessionHelper(scope);
+ Session = sessionHelper.Init();
+ }
+
+ [TestCleanup]
+ public void TestCleanup()
+ {
+ try
+ {
+ if (TestContext.CurrentTestOutcome == UnitTestOutcome.Failed)
+ {
+ CaptureFailureScreenshot();
+ }
+ }
+ catch
+ {
+ }
+
+ try
+ {
+ Session?.Cleanup();
+ }
+ catch
+ {
+ }
+
+ Dispose();
+ }
+
+ ///
+ /// On failure, grab a full-screen PNG (--capture-screen so popups / overlays are
+ /// included) and attach it to the test result. Best-effort — never throws.
+ ///
+ private void CaptureFailureScreenshot()
+ {
+ if (Session is null)
+ {
+ return;
+ }
+
+ var dir = TestContext.TestRunResultsDirectory ?? Path.GetTempPath();
+ Directory.CreateDirectory(dir);
+ var file = Path.Combine(dir, $"{TestContext.TestName}_{DateTime.Now:yyyyMMdd_HHmmss}.png");
+
+ if (Session.TryScreenshot(file, captureScreen: true) && File.Exists(file))
+ {
+ TestContext.AddResultFile(file);
+ }
+ }
+
+ /// Find an element on the session's window. Shortcut for Session.Find<T>.
+ protected T Find(By by, int timeoutMS = 5000)
+ where T : Element, new() => Session.Find(by, timeoutMS);
+
+ /// Find an element by Name. Shortcut for Session.Find<T>(By.Name(name)).
+ protected T Find(string name, int timeoutMS = 5000)
+ where T : Element, new() => Session.Find(By.Name(name), timeoutMS);
+
+ public void Dispose()
+ {
+ if (disposed)
+ {
+ return;
+ }
+
+ disposed = true;
+ GC.SuppressFinalize(this);
+ }
+}
diff --git a/src/common/UITestAutomation.Next/WinappCli.cs b/src/common/UITestAutomation.Next/WinappCli.cs
new file mode 100644
index 000000000000..1a9002f2247f
--- /dev/null
+++ b/src/common/UITestAutomation.Next/WinappCli.cs
@@ -0,0 +1,261 @@
+// 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.Text;
+using System.Text.Json;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Microsoft.PowerToys.UITest.Next;
+
+///
+/// Thin wrapper around the winappcli executable. Every public method shells out to
+/// winapp.exe, captures stdout/stderr/exit-code, and (where requested) parses the
+/// --json envelope using .
+///
+///
+///
+/// Engine prerequisites: install once with winget install Microsoft.winappcli. The CLI
+/// lands on PATH at %LOCALAPPDATA%\Microsoft\WindowsApps\winapp.exe.
+///
+///
+/// All invocations set WINAPP_CLI_TELEMETRY_OPTOUT=1 and disable update checks via
+/// WINAPP_CLI_UPDATE_CHECK=0 so the CLI never injects extra lines into stdout.
+///
+///
+public static class WinappCli
+{
+ /// Stable hint surfaced when the CLI is missing or fails — used in all error paths.
+ 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 ExecutablePath = new(ResolveExecutable);
+
+ public sealed record Result(int ExitCode, string StdOut, string StdErr, IReadOnlyList Args)
+ {
+ public bool Success => ExitCode == 0;
+
+ ///
+ /// One-line, assertion-friendly description of a failed invocation. Format:
+ /// "winapp ui invoke X -w 12345 -> exit 1; stderr: not found". Falls back to
+ /// stdout if stderr is empty.
+ ///
+ 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);
+ }
+ }
+ }
+
+ ///
+ /// Returns true when winapp.exe resolves to a real file AND responds to
+ /// --version. Use from [ClassInitialize] / [AssemblyInitialize] /
+ /// to fail the entire suite once with a clear install hint,
+ /// instead of letting every test produce its own opaque process-launch failure.
+ ///
+ public static bool IsAvailable()
+ {
+ if (!TryResolveExecutable(out _))
+ {
+ return false;
+ }
+
+ try
+ {
+ return Invoke("--version").Success;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ /// Run winapp.exe with the given arguments. Returns exit code and captured streams.
+ public static Result Invoke(params string[] 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);
+ }
+
+ using var p = StartWinappProcess(psi);
+
+ var stdoutTask = p.StandardOutput.ReadToEndAsync();
+ var stderrTask = p.StandardError.ReadToEndAsync();
+ p.WaitForExit();
+
+ return new Result(
+ p.ExitCode,
+ stdoutTask.GetAwaiter().GetResult(),
+ stderrTask.GetAwaiter().GetResult(),
+ args);
+ }
+
+ /// Run and throw if the exit code is non-zero. Use for fire-and-forget commands.
+ public static Result InvokeAssertSuccess(params string[] args)
+ {
+ var r = Invoke(args);
+ Assert.AreEqual(0, r.ExitCode, r.DescribeFailure());
+ return r;
+ }
+
+ /// Run a --json command and return the parsed root .
+ 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();
+ }
+
+ ///
+ /// Locate winapp.exe without throwing or asserting. uses
+ /// this to probe quietly; the lazy wraps it for the
+ /// first real call.
+ ///
+ 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;
+ }
+
+ ///
+ /// Start winapp.exe, retrying the transient launch failure that affects Windows App
+ /// Execution Aliases. The winapp.exe found on PATH is the reparse-point stub under
+ /// %LOCALAPPDATA%\Microsoft\WindowsApps; launching an alias through CreateProcess
+ /// (UseShellExecute = false) intermittently throws with
+ /// ERROR_INVALID_PARAMETER (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.
+ ///
+ 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);
+ }
+}
diff --git a/src/common/UITestAutomation.Next/WindowControl.cs b/src/common/UITestAutomation.Next/WindowControl.cs
new file mode 100644
index 000000000000..821a80015421
--- /dev/null
+++ b/src/common/UITestAutomation.Next/WindowControl.cs
@@ -0,0 +1,227 @@
+// 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;
+
+///
+/// Fault-tolerant window cleanup helpers. Every method swallows exceptions and returns a
+/// boolean — they're designed for test finally blocks where a cleanup failure must
+/// never mask the real test failure.
+///
+///
+/// winappcli has no close verb, so closing goes through Win32 WM_CLOSE
+/// (graceful) with an optional process-kill fallback. Focus uses SetForegroundWindow
+/// against the HWND that already discovers.
+///
+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);
+
+ private const uint WM_CLOSE = 0x0010;
+ private const int SW_RESTORE = 9;
+
+ ///
+ /// Send WM_CLOSE to every window owned by and wait
+ /// up to for them to disappear. Tolerant: returns false on
+ /// any failure instead of throwing.
+ ///
+ 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;
+ }
+ }
+
+ ///
+ /// Send WM_CLOSE to every window matching 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).
+ ///
+ public static bool TryCloseByApp(string appNameOrPid, Func 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;
+ }
+ }
+
+ ///
+ /// Bring the first window owned by to the foreground.
+ /// If the window is minimized it's first restored. Tolerant.
+ ///
+ 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;
+ }
+ }
+
+ ///
+ /// Cleanup convenience: close every window of (if any) and
+ /// bring to the foreground. Mirrors the pattern in the legacy
+ /// TestHelper.CleanupTest (close target window → re-attach to Settings) but does
+ /// not throw, so it's safe to call from a test finally.
+ ///
+ public static void SafeCloseAndFocus(string closeApp, string focusApp, int closeTimeoutMS = 5_000)
+ {
+ TryCloseByApp(closeApp, closeTimeoutMS);
+ TryFocusByApp(focusApp);
+ }
+
+ ///
+ /// Force-terminate every process whose name contains .
+ /// Use only as a last resort when failed and the
+ /// module's window must be gone before the next test starts.
+ ///
+ 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;
+ }
+ }
+
+ 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.
+ }
+ }
+}
diff --git a/src/common/UITestAutomation.Next/WindowHelper.cs b/src/common/UITestAutomation.Next/WindowHelper.cs
new file mode 100644
index 000000000000..fc5cdc1a7009
--- /dev/null
+++ b/src/common/UITestAutomation.Next/WindowHelper.cs
@@ -0,0 +1,159 @@
+// 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;
+
+/// Preset window sizes for .
+public enum WindowSize
+{
+ /// No size change.
+ UnSpecified,
+
+ /// 640 x 480.
+ Small,
+
+ /// 480 x 640.
+ Small_Vertical,
+
+ /// 1024 x 768.
+ Medium,
+
+ /// 768 x 1024.
+ Medium_Vertical,
+
+ /// 1920 x 1080.
+ Large,
+
+ /// 1080 x 1920.
+ Large_Vertical,
+}
+
+///
+/// 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 (; ).
+///
+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;
+
+ [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")]
+ 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);
+
+ /// True when any UIA-visible window's title contains (CLI-based).
+ public static bool IsWindowOpen(string titleContains) =>
+ WindowsFinder.ListAll().Any(w => w.Title.Contains(titleContains, StringComparison.OrdinalIgnoreCase));
+
+ /// Resize a window to a preset (keeps its current position).
+ public static void SetWindowSize(IntPtr hWnd, WindowSize size)
+ {
+ var (w, h) = Dimensions(size);
+ if (w > 0 && h > 0)
+ {
+ SetMainWindowSize(hWnd, w, h);
+ }
+ }
+
+ /// Resize a window to explicit width/height (keeps its current position).
+ public static void SetMainWindowSize(IntPtr hWnd, int width, int height) =>
+ SetWindowPos(hWnd, IntPtr.Zero, 0, 0, width, height, SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE);
+
+ /// (Left, Top, Right, Bottom) of the window in screen pixels.
+ 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);
+ }
+
+ /// Center point of the window in screen pixels.
+ 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));
+ }
+
+ /// Primary display size in pixels.
+ public static (int Width, int Height) GetDisplaySize() =>
+ (GetSystemMetrics(SM_CXSCREEN), GetSystemMetrics(SM_CYSCREEN));
+
+ /// Center of the primary display in pixels.
+ public static (int CenterX, int CenterY) GetScreenCenter()
+ {
+ var (w, h) = GetDisplaySize();
+ return (w / 2, h / 2);
+ }
+
+ /// Color of the on-screen pixel at (, ) via GDI.
+ 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);
+ }
+ }
+
+ /// On-screen pixel color at (, ) as #RRGGBB.
+ 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),
+ };
+}
diff --git a/src/common/UITestAutomation.Next/Windows.cs b/src/common/UITestAutomation.Next/Windows.cs
new file mode 100644
index 000000000000..be4c0adaff55
--- /dev/null
+++ b/src/common/UITestAutomation.Next/Windows.cs
@@ -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;
+
+///
+/// Static helpers for discovering and attaching to windows that aren't the test's primary scope.
+///
+///
+/// Most tests target one module's main window (handled by + ).
+/// 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
+/// winapp ui list-windows --json to find/wait for those windows by process or title.
+///
+public static class WindowsFinder
+{
+ public sealed record WindowInfo(long Hwnd, string Title, string ProcessName, int ProcessId, string ClassName, int Width, int Height);
+
+ /// List all UIA-visible windows.
+ ///
+ /// NOTE: winappcli's unfiltered list-windows --json 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 with a process/PID filter when you need to see
+ /// those — winappcli returns them in the filtered form.
+ ///
+ public static IReadOnlyList ListAll() => Parse(WinappCli.Invoke("ui", "list-windows", "--json"));
+
+ ///
+ /// List UIA-visible windows belonging to (process name substring or PID).
+ /// Uses winappcli's -a filter, which works around the bug where unfiltered
+ /// list-windows drops windows without a Win32 title.
+ ///
+ public static IReadOnlyList ListByApp(string appNameOrPid) =>
+ Parse(WinappCli.Invoke("ui", "list-windows", "-a", appNameOrPid, "--json"));
+
+ private static IReadOnlyList Parse(WinappCli.Result r)
+ {
+ if (!r.Success || string.IsNullOrEmpty(r.StdOut))
+ {
+ return Array.Empty();
+ }
+
+ try
+ {
+ using var doc = JsonDocument.Parse(r.StdOut);
+ if (doc.RootElement.ValueKind != JsonValueKind.Array)
+ {
+ return Array.Empty();
+ }
+
+ var list = new List();
+ 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();
+ }
+ }
+
+ ///
+ /// Poll until a window matching appears, or
+ /// elapses. Returns the window's wrapper on success.
+ ///
+ public static Session? WaitForWindow(Func 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;
+ }
+
+ /// Convenience wrapper: wait for a window with the given title substring.
+ public static Session? WaitForWindowByTitle(string titleContains, int timeoutMS = 10_000)
+ => WaitForWindow(w => w.Title.Contains(titleContains, StringComparison.OrdinalIgnoreCase), timeoutMS: timeoutMS);
+
+ ///
+ /// Wait for any window owned by a process whose name contains .
+ /// Uses winappcli's -a filter under the hood so untitled windows (e.g. the ColorPicker
+ /// editor) are discoverable — the unfiltered list-windows drops those.
+ ///
+ 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;
+ }
+
+ ///
+ /// Same as but filters with .
+ /// Use when the same process owns multiple windows (e.g. ColorPickerUI exposes both the
+ /// small picker overlay and the larger editor window).
+ ///
+ public static Session? WaitForWindowByApp(
+ string appNameOrPid,
+ Func 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;
+ }
+}
diff --git a/src/modules/colorPicker/ColorPicker.UITests/AssemblyInfo.cs b/src/modules/colorPicker/ColorPicker.UITests/AssemblyInfo.cs
new file mode 100644
index 000000000000..63be32da804e
--- /dev/null
+++ b/src/modules/colorPicker/ColorPicker.UITests/AssemblyInfo.cs
@@ -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]
diff --git a/src/modules/colorPicker/ColorPicker.UITests/ColorPicker.UITests.csproj b/src/modules/colorPicker/ColorPicker.UITests/ColorPicker.UITests.csproj
new file mode 100644
index 000000000000..97629ada60fd
--- /dev/null
+++ b/src/modules/colorPicker/ColorPicker.UITests/ColorPicker.UITests.csproj
@@ -0,0 +1,32 @@
+
+
+ Exe
+ net10.0-windows10.0.26100.0
+ enable
+ enable
+ false
+ false
+ Microsoft.ColorPicker.UITests
+ ColorPicker.UITests
+
+
+ true
+ true
+ false
+
+
+ false
+
+
+
+
+
+
+
+
+
+
diff --git a/src/modules/colorPicker/ColorPicker.UITests/ColorPickerEndToEndTests.cs b/src/modules/colorPicker/ColorPicker.UITests/ColorPickerEndToEndTests.cs
new file mode 100644
index 000000000000..c555ad545c76
--- /dev/null
+++ b/src/modules/colorPicker/ColorPicker.UITests/ColorPickerEndToEndTests.cs
@@ -0,0 +1,428 @@
+// 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;
+
+///
+/// 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 PowerToys.ColorPickerUI exits.
+/// 3. Toggle it back ON and verify PowerToys.ColorPickerUI respawns.
+/// 4. Read the activation shortcut from the page's ShortcutControl (the EditButton
+/// exposes HotkeySettings.ToString() via AutomationProperties.HelpText).
+/// 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.
+///
+///
+/// The overlay's visible ColorTextBlock has AutomationProperties.Name="{Binding ColorName}"
+/// 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 ColorText with
+/// AutomationId="ColorHexAutomationPeer" — a test-only UIA hook that lets us read the
+/// actually-displayed HEX value without affecting the visual layout or accessibility UX.
+///
+[TestClass]
+public class ColorPickerEndToEndTests : UITestBase
+{
+ public ColorPickerEndToEndTests()
+ : base(PowerToysModule.PowerToysSettings)
+ {
+ }
+
+ [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(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(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