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 @@ stdcpplatest false - _SILENCE_STDEXT_ARR_ITERS_DEPRECATION_WARNING;_UNICODE;UNICODE;%(PreprocessorDefinitions) + + _SILENCE_STDEXT_ARR_ITERS_DEPRECATION_WARNING;_SILENCE_EXPERIMENTAL_COROUTINE_DEPRECATION_WARNINGS;_UNICODE;UNICODE;%(PreprocessorDefinitions) Guard ProgramDatabase 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