From 3bc472bcdaf6a1b86fcd586aeca17e3a5d46e2f5 Mon Sep 17 00:00:00 2001 From: gkhmyznikov Date: Wed, 10 Jun 2026 11:18:17 -0700 Subject: [PATCH 01/43] initial commit --- PowerToys.slnx | 26 +- src/common/UITestAutomation.Next/By.cs | 49 ++ .../UITestAutomation.Next/Element/Button.cs | 13 + .../UITestAutomation.Next/Element/Element.cs | 211 +++++++++ .../Element/NavigationViewItem.cs | 14 + .../UITestAutomation.Next/Element/TextBox.cs | 48 ++ .../Element/ToggleSwitch.cs | 32 ++ .../UITestAutomation.Next/Element/Window.cs | 13 + .../UITestAutomation.Next/KeyboardHelper.cs | 118 +++++ .../UITestAutomation.Next/ModuleConfigData.cs | 48 ++ .../UITestAutomation.Next/MouseHelper.cs | 55 +++ src/common/UITestAutomation.Next/Session.cs | 196 ++++++++ .../UITestAutomation.Next/SessionHelper.cs | 133 ++++++ .../UITestAutomation.Next.csproj | 25 + .../UITestAutomation.Next/UITestBase.cs | 79 ++++ src/common/UITestAutomation.Next/WinappCli.cs | 162 +++++++ .../UITestAutomation.Next/WindowControl.cs | 227 +++++++++ src/common/UITestAutomation.Next/Windows.cs | 155 +++++++ .../UITest-ColorPicker.Next/AssemblyInfo.cs | 10 + .../ColorPickerEditorTests.cs | 102 +++++ .../ColorPickerEndToEndTests.cs | 433 ++++++++++++++++++ .../ColorPickerSettingsFile.cs | 87 ++++ .../ColorPickerToggleTests.cs | 92 ++++ .../UITest-ColorPicker.Next.csproj | 32 ++ 24 files changed, 2351 insertions(+), 9 deletions(-) create mode 100644 src/common/UITestAutomation.Next/By.cs create mode 100644 src/common/UITestAutomation.Next/Element/Button.cs create mode 100644 src/common/UITestAutomation.Next/Element/Element.cs create mode 100644 src/common/UITestAutomation.Next/Element/NavigationViewItem.cs create mode 100644 src/common/UITestAutomation.Next/Element/TextBox.cs create mode 100644 src/common/UITestAutomation.Next/Element/ToggleSwitch.cs create mode 100644 src/common/UITestAutomation.Next/Element/Window.cs create mode 100644 src/common/UITestAutomation.Next/KeyboardHelper.cs create mode 100644 src/common/UITestAutomation.Next/ModuleConfigData.cs create mode 100644 src/common/UITestAutomation.Next/MouseHelper.cs create mode 100644 src/common/UITestAutomation.Next/Session.cs create mode 100644 src/common/UITestAutomation.Next/SessionHelper.cs create mode 100644 src/common/UITestAutomation.Next/UITestAutomation.Next.csproj create mode 100644 src/common/UITestAutomation.Next/UITestBase.cs create mode 100644 src/common/UITestAutomation.Next/WinappCli.cs create mode 100644 src/common/UITestAutomation.Next/WindowControl.cs create mode 100644 src/common/UITestAutomation.Next/Windows.cs create mode 100644 src/modules/colorPicker/UITest-ColorPicker.Next/AssemblyInfo.cs create mode 100644 src/modules/colorPicker/UITest-ColorPicker.Next/ColorPickerEditorTests.cs create mode 100644 src/modules/colorPicker/UITest-ColorPicker.Next/ColorPickerEndToEndTests.cs create mode 100644 src/modules/colorPicker/UITest-ColorPicker.Next/ColorPickerSettingsFile.cs create mode 100644 src/modules/colorPicker/UITest-ColorPicker.Next/ColorPickerToggleTests.cs create mode 100644 src/modules/colorPicker/UITest-ColorPicker.Next/UITest-ColorPicker.Next.csproj diff --git a/PowerToys.slnx b/PowerToys.slnx index a456003add84..337f6e6d3a94 100644 --- a/PowerToys.slnx +++ b/PowerToys.slnx @@ -9,11 +9,11 @@ - + - + @@ -54,10 +54,14 @@ + + + + - + @@ -184,6 +188,10 @@ + + + + @@ -200,11 +208,11 @@ - + - + @@ -715,11 +723,11 @@ - + - + @@ -1119,14 +1127,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/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/Element.cs b/src/common/UITestAutomation.Next/Element/Element.cs new file mode 100644 index 000000000000..60f411ad9bf7 --- /dev/null +++ b/src/common/UITestAutomation.Next/Element/Element.cs @@ -0,0 +1,211 @@ +// 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 Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.PowerToys.UITest.Next; + +/// +/// 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, "-w", Owner!.WindowHandleArg, "--right"); + } + else + { + WinappCli.InvokeAssertSuccess("ui", "invoke", Selector, "-w", Owner!.WindowHandleArg); + } + + 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, "-w", Owner!.WindowHandleArg); + if (msPostAction > 0) + { + Thread.Sleep(msPostAction); + } + } + + /// Move keyboard focus to this element. + public void Focus() + { + EnsureBound(); + WinappCli.InvokeAssertSuccess("ui", "focus", Selector, "-w", Owner!.WindowHandleArg); + } + + /// + /// 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 root = WinappCli.InvokeJson("ui", "get-property", Selector, "-p", propertyName, "-w", Owner!.WindowHandleArg, "--json"); + if (root.TryGetProperty("properties", out var props) && + props.TryGetProperty(propertyName, out var v)) + { + return v.GetString() ?? string.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"); + + /// + /// 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, "-w", Owner!.WindowHandleArg, "--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, + "-w", Owner!.WindowHandleArg, + "--property", propertyName, + "--value", expectedValue, + "-t", timeoutMS.ToString(System.Globalization.CultureInfo.InvariantCulture)); + return r.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, + "-w", Owner!.WindowHandleArg, + "--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); + + private void EnsureBound() + { + Assert.IsNotNull(Owner, "Element is not bound to a Session."); + Assert.IsFalse(string.IsNullOrEmpty(Selector), "Element has no selector."); + } +} 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/TextBox.cs b/src/common/UITestAutomation.Next/Element/TextBox.cs new file mode 100644 index 000000000000..bdba74ca1519 --- /dev/null +++ b/src/common/UITestAutomation.Next/Element/TextBox.cs @@ -0,0 +1,48 @@ +// 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; + +/// 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) + { + Assert.IsNotNull(Owner, "TextBox is not bound to a Session."); + WinappCli.InvokeAssertSuccess("ui", "set-value", Selector, value, "-w", Owner!.WindowHandleArg); + return this; + } + + /// Current text content via winapp ui get-value. + public string Value + { + get + { + Assert.IsNotNull(Owner, "TextBox is not bound to a Session."); + var r = WinappCli.Invoke("ui", "get-value", Selector, "-w", Owner!.WindowHandleArg, "--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/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/KeyboardHelper.cs b/src/common/UITestAutomation.Next/KeyboardHelper.cs new file mode 100644 index 000000000000..bf1945993d0e --- /dev/null +++ b/src/common/UITestAutomation.Next/KeyboardHelper.cs @@ -0,0 +1,118 @@ +// 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, + + 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, +} + +/// +/// 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 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; + 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); + } + } + } +} diff --git a/src/common/UITestAutomation.Next/ModuleConfigData.cs b/src/common/UITestAutomation.Next/ModuleConfigData.cs new file mode 100644 index 000000000000..a6a7427e6247 --- /dev/null +++ b/src/common/UITestAutomation.Next/ModuleConfigData.cs @@ -0,0 +1,48 @@ +// 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, +} + +/// Resolves installer paths and process metadata for a . +internal static class ModulePaths +{ + private static readonly string Root = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), + "PowerToys"); + + public static string ExePathFor(PowerToysModule module) => module switch + { + PowerToysModule.PowerToysSettings => Path.Combine(Root, "WinUI3Apps", "PowerToys.Settings.exe"), + PowerToysModule.Runner => Path.Combine(Root, "PowerToys.exe"), + PowerToysModule.ColorPicker => Path.Combine(Root, "PowerToys.ColorPickerUI.exe"), + _ => throw new ArgumentOutOfRangeException(nameof(module), module, null), + }; + + /// Process name as winappcli's -a flag accepts it (case-insensitive substring). + public static string ProcessNameFor(PowerToysModule module) => module switch + { + PowerToysModule.PowerToysSettings => "PowerToys.Settings", + PowerToysModule.Runner => "PowerToys", + PowerToysModule.ColorPicker => "PowerToys.ColorPickerUI", + _ => throw new ArgumentOutOfRangeException(nameof(module), module, null), + }; + + /// Expected window title substring; used to pick the right HWND when a module has several windows. + public static string MainWindowTitleFor(PowerToysModule module) => module switch + { + PowerToysModule.PowerToysSettings => "PowerToys Settings", + PowerToysModule.ColorPicker => "PowerToys.ColorPickerUI", + _ => string.Empty, + }; +} diff --git a/src/common/UITestAutomation.Next/MouseHelper.cs b/src/common/UITestAutomation.Next/MouseHelper.cs new file mode 100644 index 000000000000..de034ef58cc8 --- /dev/null +++ b/src/common/UITestAutomation.Next/MouseHelper.cs @@ -0,0 +1,55 @@ +// 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 +{ + [DllImport("user32.dll")] + private static extern bool SetCursorPos(int x, int y); + + [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 + + private const uint MOUSEEVENTF_LEFTDOWN = 0x02; + private const uint MOUSEEVENTF_LEFTUP = 0x04; + private const uint MOUSEEVENTF_RIGHTDOWN = 0x08; + private const uint MOUSEEVENTF_RIGHTUP = 0x10; + + /// Move the OS cursor to absolute screen coordinates. + public static void MoveTo(int x, int y) => SetCursorPos(x, y); + + /// Press + release left mouse button at the current cursor position. + public static void LeftClick() + { + mouse_event(MOUSEEVENTF_LEFTDOWN, 0, 0, 0, UIntPtr.Zero); + Thread.Sleep(50); + mouse_event(MOUSEEVENTF_LEFTUP, 0, 0, 0, UIntPtr.Zero); + } + + /// 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() + { + mouse_event(MOUSEEVENTF_RIGHTDOWN, 0, 0, 0, UIntPtr.Zero); + Thread.Sleep(50); + mouse_event(MOUSEEVENTF_RIGHTUP, 0, 0, 0, UIntPtr.Zero); + } +} diff --git a/src/common/UITestAutomation.Next/Session.cs b/src/common/UITestAutomation.Next/Session.cs new file mode 100644 index 000000000000..1830d0cf72f0 --- /dev/null +++ b/src/common/UITestAutomation.Next/Session.cs @@ -0,0 +1,196 @@ +// 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 a specific HWND. All / +/// calls route to winapp ui search with -w <hex hwnd> for stable targeting. +/// +public sealed class Session +{ + /// Decimal HWND of the target window (as returned by list-windows --json). + public long WindowHandle { get; } + + /// String form of for passing to winappcli's -w flag. + public string WindowHandleArg { get; } + + public string WindowTitle { get; } + + public int ProcessId { get; } + + public string ProcessName { get; } + + public PowerToysModule InitScope { get; } + + internal Session(PowerToysModule scope, long hwnd, string title, int pid, string processName) + { + InitScope = scope; + WindowHandle = hwnd; + WindowHandleArg = hwnd.ToString(CultureInfo.InvariantCulture); + WindowTitle = title; + ProcessId = pid; + ProcessName = processName; + } + + 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; + } + + /// Capture a PNG of the session's window via winapp ui screenshot. + public string Screenshot(string outputPath) + { + WinappCli.InvokeAssertSuccess("ui", "screenshot", "-w", WindowHandleArg, "-o", outputPath); + return outputPath; + } + + /// + /// Dump the full UIA tree for this session's window via winapp ui inspect --json. + /// Returned shape: { "windows": [{ "elements": [{ "type", "name", "value", "children": [...] }] }] }. + /// + public JsonElement Inspect(int depth = 6) + { + return WinappCli.InvokeJson( + "ui", "inspect", + "-w", WindowHandleArg, + "--json", + "-d", depth.ToString(CultureInfo.InvariantCulture)); + } + + /// 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, "-w", WindowHandleArg, "--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..8c793d9e736d --- /dev/null +++ b/src/common/UITestAutomation.Next/SessionHelper.cs @@ -0,0 +1,133 @@ +// 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. +/// +internal sealed class SessionHelper +{ + private readonly PowerToysModule scope; + + public SessionHelper(PowerToysModule scope) + { + this.scope = scope; + } + + public Session Init() + { + var exe = ModulePaths.ExePathFor(scope); + Assert.IsTrue(File.Exists(exe), $"Module exe not found: {exe}"); + + // Reuse a running instance if present (matches Settings UI single-instance behaviour). + if (!IsRunning(exe)) + { + try + { + Process.Start(new ProcessStartInfo + { + FileName = exe, + UseShellExecute = true, + }); + } + catch (Exception ex) + { + Assert.Fail($"Failed to launch {exe}: {ex.Message}"); + } + } + + var window = WaitForMainWindow(scope, TimeSpan.FromSeconds(20)); + Assert.IsNotNull(window, $"Main window for {scope} did not appear via winappcli within 20s"); + return window!; + } + + private static bool IsRunning(string exePath) + { + var name = Path.GetFileNameWithoutExtension(exePath); + return Process.GetProcessesByName(name).Length > 0; + } + + /// + /// 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/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..19be53289609 --- /dev/null +++ b/src/common/UITestAutomation.Next/UITestBase.cs @@ -0,0 +1,79 @@ +// 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 +{ + 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() + { + sessionHelper = new SessionHelper(scope); + Session = sessionHelper.Init(); + } + + [TestCleanup] + public void TestCleanup() + { + try + { + Session?.Cleanup(); + } + catch + { + } + + Dispose(); + } + + /// 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..2d1c889456ba --- /dev/null +++ b/src/common/UITestAutomation.Next/WinappCli.cs @@ -0,0 +1,162 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Diagnostics; +using System.Text; +using 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 +{ + private static readonly Lazy ExecutablePath = new(ResolveExecutable); + + public sealed record Result(int ExitCode, string StdOut, string StdErr) + { + public bool Success => ExitCode == 0; + + public JsonDocument ParseJson() + { + try + { + return JsonDocument.Parse(StdOut); + } + catch (JsonException ex) + { + throw new InvalidOperationException( + $"winappcli stdout was not valid JSON (exit {ExitCode}). stdout={StdOut.Trim()} stderr={StdErr.Trim()}", + ex); + } + } + } + + /// 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 = Process.Start(psi) ?? throw new InvalidOperationException( + $"Failed to start winapp.exe ({ExecutablePath.Value})"); + + var stdoutTask = p.StandardOutput.ReadToEndAsync(); + var stderrTask = p.StandardError.ReadToEndAsync(); + p.WaitForExit(); + + return new Result(p.ExitCode, stdoutTask.GetAwaiter().GetResult(), stderrTask.GetAwaiter().GetResult()); + } + + /// 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, $"winappcli failed: winapp {string.Join(' ', args)}\nstdout: {r.StdOut}\nstderr: {r.StdErr}"); + 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($"winappcli {string.Join(' ', args)} exited with {r.ExitCode} and non-JSON stdout: {r.StdOut.Trim()} stderr: {r.StdErr.Trim()}"); + return default; + } + } + + using var ok = JsonDocument.Parse(r.StdOut); + return ok.RootElement.Clone(); + } + + private static string ResolveExecutable() + { + // 1) Explicit override (CI / dev convenience). + var env = Environment.GetEnvironmentVariable("WINAPP_CLI_PATH"); + if (!string.IsNullOrEmpty(env) && File.Exists(env)) + { + return env; + } + + // 2) Standard winget install location. + var winget = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "Microsoft", + "WindowsApps", + "winapp.exe"); + if (File.Exists(winget)) + { + return winget; + } + + // 3) Anything on PATH. + var path = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; + foreach (var dir in path.Split(Path.PathSeparator)) + { + if (string.IsNullOrWhiteSpace(dir)) + { + continue; + } + + try + { + var candidate = Path.Combine(dir, "winapp.exe"); + if (File.Exists(candidate)) + { + return candidate; + } + } + catch + { + } + } + + Assert.Fail( + "winapp.exe not found. Install once with: winget install Microsoft.winappcli " + + "or set WINAPP_CLI_PATH to its full path."); + return string.Empty; + } +} 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/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/UITest-ColorPicker.Next/AssemblyInfo.cs b/src/modules/colorPicker/UITest-ColorPicker.Next/AssemblyInfo.cs new file mode 100644 index 000000000000..63be32da804e --- /dev/null +++ b/src/modules/colorPicker/UITest-ColorPicker.Next/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/UITest-ColorPicker.Next/ColorPickerEditorTests.cs b/src/modules/colorPicker/UITest-ColorPicker.Next/ColorPickerEditorTests.cs new file mode 100644 index 000000000000..9646725ed1bb --- /dev/null +++ b/src/modules/colorPicker/UITest-ColorPicker.Next/ColorPickerEditorTests.cs @@ -0,0 +1,102 @@ +// 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 Microsoft.PowerToys.UITest.Next; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.ColorPicker.UITests.Next; + +/// +/// Real ColorPicker module test: assert that toggling the dashboard switch actually causes the +/// PowerToys runner to spawn / terminate the ColorPickerUI process. This exercises: +/// Settings UI (ToggleSwitch) → settings.json write → runner module-state poll → +/// ColorPicker.dll enable()/disable() → ColorPickerUI process lifecycle. +/// +/// +/// +/// We deliberately do NOT try to trigger Win+Shift+C from this test. The ColorPicker +/// activation goes through the runner's WH_KEYBOARD_LL hook + a named-event signal +/// (m_hInvokeEvent in ColorPicker/dllmain.cpp) and synthetic keystrokes from +/// keybd_event/SendInput are unreliable through this chain (focus, virtual +/// desktops, runner-process-vs-test-process associations all interfere). The existing +/// repo harness has the same limitation — that's why ColorPickerUITest.cs ships +/// today as an empty stub. +/// +/// +/// Plus, the picker overlay itself (MainWindow.xaml) is +/// AllowsTransparency=True, Opacity=0.1, ShowInTaskbar=False — UIA correctly hides it, +/// so even if the hotkey fired there would be nothing to assert against. Proper picker +/// testing would need either an in-process signal-event hook (a new ColorPicker test API) or +/// hardware-level HID injection (out of scope for a UI test harness). +/// +/// +[TestClass] +public class ColorPickerModuleLifecycleTests : UITestBase +{ + public ColorPickerModuleLifecycleTests() + : base(PowerToysModule.PowerToysSettings) + { + } + + [TestMethod] + [TestCategory("ColorPicker")] + [TestCategory("winappcli-POC")] + public void TogglingModuleStartsAndStopsColorPickerProcess() + { + var toggle = Find("Color Picker"); + var initial = toggle.IsOn; + + try + { + // Force module OFF and assert the process actually exits. + if (toggle.IsOn) + { + toggle.Toggle(false); + Assert.IsTrue( + WaitForProcess("PowerToys.ColorPickerUI", expected: false, timeoutMS: 10_000), + "PowerToys.ColorPickerUI did not exit within 10s after toggling module OFF."); + } + + // Force module ON and assert the runner spawns the process. + toggle.Toggle(true); + Assert.IsTrue( + WaitForProcess("PowerToys.ColorPickerUI", expected: true, timeoutMS: 10_000), + "PowerToys.ColorPickerUI did not start within 10s after toggling module ON."); + + // Cycle once more to prove the lifecycle is repeatable. + toggle.Toggle(false); + Assert.IsTrue( + WaitForProcess("PowerToys.ColorPickerUI", expected: false, timeoutMS: 10_000), + "Second OFF cycle: PowerToys.ColorPickerUI did not exit."); + + toggle.Toggle(true); + Assert.IsTrue( + WaitForProcess("PowerToys.ColorPickerUI", expected: true, timeoutMS: 10_000), + "Second ON cycle: PowerToys.ColorPickerUI did not start."); + } + finally + { + // Restore initial state regardless of pass/fail. + toggle.Toggle(initial); + } + } + + private static bool WaitForProcess(string name, bool expected, int timeoutMS) + { + var deadline = DateTime.UtcNow + TimeSpan.FromMilliseconds(timeoutMS); + while (DateTime.UtcNow < deadline) + { + var running = Process.GetProcessesByName(name).Length > 0; + if (running == expected) + { + return true; + } + + Thread.Sleep(250); + } + + return false; + } +} diff --git a/src/modules/colorPicker/UITest-ColorPicker.Next/ColorPickerEndToEndTests.cs b/src/modules/colorPicker/UITest-ColorPicker.Next/ColorPickerEndToEndTests.cs new file mode 100644 index 000000000000..84295284a2c2 --- /dev/null +++ b/src/modules/colorPicker/UITest-ColorPicker.Next/ColorPickerEndToEndTests.cs @@ -0,0 +1,433 @@ +// 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.Next; + +/// +/// 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 left nav +/// (NavigationViewItem "ColorPickerNavItem"). +/// 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. Move the cursor, send the shortcut chord. +/// 6. Wait for the small picker overlay window, read the displayed HEX value. +/// 7. Left-click to capture and open the editor. +/// 8. Wait for the editor window and assert the same HEX appears in its tree. +/// +/// +/// Inspired by ScreenRuler.UITests/TestHelper.cs's pattern of navigating via the +/// settings nav item, flipping the toggle, and reading the shortcut from the +/// EditButton.HelpText — but driven through winappcli with no third-party engine. +/// +[TestClass] +public class ColorPickerEndToEndTests : UITestBase +{ + public ColorPickerEndToEndTests() + : base(PowerToysModule.PowerToysSettings) + { + } + + [TestMethod] + [TestCategory("ColorPicker")] + [TestCategory("winappcli-POC")] + public void NavigateReadShortcutActivateAndCapture() + { + // Flip the picker to two-line layout (`showcolorname=true`) for the test. In one-line mode + // (the default) the single ColorTextBlock has AutomationProperties.Name="{Binding ColorName}" + // which masks its rendered Text — UIA only exposes the friendly name ("Black"), not the HEX. + // In two-line mode the HEX TextBlock has no Name override, so WPF/UIA falls back to + // exposing its rendered Text as the UIA Name. We restore the original value in finally. + var originalShowColorName = ColorPickerSettingsFile.ReadShowColorName(); + if (!originalShowColorName) + { + ColorPickerSettingsFile.WriteShowColorName(true); + Thread.Sleep(500); // FileSystemWatcher debounce + UI rebind + } + + 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"); + + if (!originalShowColorName) + { + ColorPickerSettingsFile.WriteShowColorName(false); + } + } + } + + 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