diff --git a/.pipelines/v2/templates/job-test-project.yml b/.pipelines/v2/templates/job-test-project.yml index 234f477fb6..9be8f6ca14 100644 --- a/.pipelines/v2/templates/job-test-project.yml +++ b/.pipelines/v2/templates/job-test-project.yml @@ -102,7 +102,6 @@ jobs: uiTests: true rerunFailedTests: true testAssemblyVer2: | - **\UITests-FancyZones.dll - **\UITests-FancyZonesEditor.dll + **\*UITest*.dll !**\obj\** !**\ref\** \ No newline at end of file diff --git a/.pipelines/verifyDepsJsonLibraryVersions.ps1 b/.pipelines/verifyDepsJsonLibraryVersions.ps1 index e85ca1d991..9ff05c61b1 100644 --- a/.pipelines/verifyDepsJsonLibraryVersions.ps1 +++ b/.pipelines/verifyDepsJsonLibraryVersions.ps1 @@ -91,5 +91,4 @@ if ($totalFailures -gt 0) { } Write-Host -ForegroundColor Green "All " $referencedFileVersionsPerDll.keys.Count " libraries are mentioned with the same version across the dependencies.`r`n" -exit 0 - +exit 0 \ No newline at end of file diff --git a/src/common/UITestAutomation/Element/Element.cs b/src/common/UITestAutomation/Element/Element.cs index 7ca0cf53a5..8a2ec6dc71 100644 --- a/src/common/UITestAutomation/Element/Element.cs +++ b/src/common/UITestAutomation/Element/Element.cs @@ -191,7 +191,7 @@ namespace Microsoft.PowerToys.UITest /// Send Key of the element. /// /// The Key to Send. - public void SendKeys(string key) + public void InputText(string key) { PerformAction((actions, windowElement) => { @@ -218,7 +218,7 @@ namespace Microsoft.PowerToys.UITest /// The selector to use for finding the element. /// The timeout in milliseconds. /// The found element. - public T Find(By by, int timeoutMS = 3000) + public T Find(By by, int timeoutMS = 5000) where T : Element, new() { Assert.IsNotNull(this.windowsElement, $"WindowsElement is null in method Find<{typeof(T).Name}> with parameters: by = {by}, timeoutMS = {timeoutMS}"); @@ -226,7 +226,7 @@ namespace Microsoft.PowerToys.UITest // leverage findAll to filter out mismatched elements var collection = this.FindAll(by, timeoutMS); - Assert.IsTrue(collection.Count > 0, $"Element not found using selector: {by}"); + Assert.IsTrue(collection.Count > 0, $"UI-Element({typeof(T).Name}) not found using selector: {by}"); return collection[0]; } @@ -239,7 +239,7 @@ namespace Microsoft.PowerToys.UITest /// The name for finding the element. /// The timeout in milliseconds. /// The found element. - public T Find(string name, int timeoutMS = 3000) + public T Find(string name, int timeoutMS = 5000) where T : Element, new() { return this.Find(By.Name(name), timeoutMS); @@ -252,7 +252,7 @@ namespace Microsoft.PowerToys.UITest /// The selector to use for finding the element. /// The timeout in milliseconds. /// The found element. - public Element Find(By by, int timeoutMS = 3000) + public Element Find(By by, int timeoutMS = 5000) { return this.Find(by, timeoutMS); } @@ -264,7 +264,7 @@ namespace Microsoft.PowerToys.UITest /// The name for finding the element. /// The timeout in milliseconds. /// The found element. - public Element Find(string name, int timeoutMS = 3000) + public Element Find(string name, int timeoutMS = 5000) { return this.Find(By.Name(name), timeoutMS); } @@ -276,7 +276,7 @@ namespace Microsoft.PowerToys.UITest /// The selector to use for finding the elements. /// The timeout in milliseconds. /// A read-only collection of the found elements. - public ReadOnlyCollection FindAll(By by, int timeoutMS = 3000) + public ReadOnlyCollection FindAll(By by, int timeoutMS = 5000) where T : Element, new() { Assert.IsNotNull(this.windowsElement, $"WindowsElement is null in method FindAll<{typeof(T).Name}> with parameters: by = {by}, timeoutMS = {timeoutMS}"); @@ -308,7 +308,7 @@ namespace Microsoft.PowerToys.UITest /// The name for finding the element. /// The timeout in milliseconds. /// A read-only collection of the found elements. - public ReadOnlyCollection FindAll(string name, int timeoutMS = 3000) + public ReadOnlyCollection FindAll(string name, int timeoutMS = 5000) where T : Element, new() { return this.FindAll(By.Name(name), timeoutMS); @@ -321,7 +321,7 @@ namespace Microsoft.PowerToys.UITest /// The selector to use for finding the elements. /// The timeout in milliseconds. /// A read-only collection of the found elements. - public ReadOnlyCollection FindAll(By by, int timeoutMS = 3000) + public ReadOnlyCollection FindAll(By by, int timeoutMS = 5000) { return this.FindAll(by, timeoutMS); } @@ -333,7 +333,7 @@ namespace Microsoft.PowerToys.UITest /// The name for finding the element. /// The timeout in milliseconds. /// A read-only collection of the found elements. - public ReadOnlyCollection FindAll(string name, int timeoutMS = 3000) + public ReadOnlyCollection FindAll(string name, int timeoutMS = 5000) { return this.FindAll(By.Name(name), timeoutMS); } @@ -360,5 +360,20 @@ namespace Microsoft.PowerToys.UITest Task.Delay(msPostAction).Wait(); } } + + /// + /// Save UI Element to a PNG file. + /// + /// the full path + internal void SaveToPngFile(string path, bool eraseUserPreferenceColor) + { + Assert.IsNotNull(this.windowsElement, $"WindowsElement is null in method SaveToFile with parameter: path = {path}"); + this.windowsElement.GetScreenshot().SaveAsFile(path); + + if (eraseUserPreferenceColor) + { + VisualHelper.EraseUserPreferenceColor(path); + } + } } } diff --git a/src/common/UITestAutomation/Element/TextBox.cs b/src/common/UITestAutomation/Element/TextBox.cs index 71f833625d..932f6058b5 100644 --- a/src/common/UITestAutomation/Element/TextBox.cs +++ b/src/common/UITestAutomation/Element/TextBox.cs @@ -2,8 +2,6 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -using OpenQA.Selenium; - namespace Microsoft.PowerToys.UITest { /// @@ -35,8 +33,8 @@ namespace Microsoft.PowerToys.UITest PerformAction((actions, windowElement) => { // select all text and delete it - windowElement.SendKeys(Keys.Control + "a"); - windowElement.SendKeys(Keys.Delete); + windowElement.SendKeys(OpenQA.Selenium.Keys.Control + "a"); + windowElement.SendKeys(OpenQA.Selenium.Keys.Delete); }); } diff --git a/src/common/UITestAutomation/FindHelper.cs b/src/common/UITestAutomation/FindHelper.cs index 465f1206a1..00f72edc77 100644 --- a/src/common/UITestAutomation/FindHelper.cs +++ b/src/common/UITestAutomation/FindHelper.cs @@ -33,7 +33,7 @@ namespace Microsoft.PowerToys.UITest public static ReadOnlyCollection? FindAll(Func> findElementsFunc, WindowsDriver? driver, int timeoutMS) where T : Element, new() { - var items = findElementsFunc(); + var items = FindElementsWithRetry(findElementsFunc, timeoutMS); var res = items.Select(item => { var element = item as WindowsElement; @@ -43,6 +43,27 @@ namespace Microsoft.PowerToys.UITest return new ReadOnlyCollection(res); } + private static ReadOnlyCollection FindElementsWithRetry(Func> findElementsFunc, int timeoutMS) + { + int retryIntervalMS = 500; + timeoutMS = 1; + int elapsedTime = 0; + + while (elapsedTime < timeoutMS) + { + var items = findElementsFunc(); + if (items.Count > 0) + { + return items; + } + + Task.Delay(retryIntervalMS).Wait(); + elapsedTime += retryIntervalMS; + } + + return new ReadOnlyCollection(new List()); + } + public static T NewElement(WindowsElement? element, WindowsDriver? driver, int timeoutMS) where T : Element, new() { @@ -50,11 +71,6 @@ namespace Microsoft.PowerToys.UITest Assert.IsNotNull(element, $"New Element {typeof(T).Name} error: element is null."); T newElement = new T(); - if (timeoutMS > 0) - { - // Only set timeout if it is positive value - driver.Manage().Timeouts().ImplicitWait = TimeSpan.FromMilliseconds(timeoutMS); - } newElement.SetSession(driver); newElement.SetWindowsElement(element); diff --git a/src/common/UITestAutomation/KeyboardHelper.cs b/src/common/UITestAutomation/KeyboardHelper.cs new file mode 100644 index 0000000000..8a086f1bf7 --- /dev/null +++ b/src/common/UITestAutomation/KeyboardHelper.cs @@ -0,0 +1,287 @@ +// 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; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.PowerToys.UITest +{ + /// + /// Represents keyboard keys. + /// + public enum Key + { + Ctrl, + Alt, + Shift, + Tab, + Esc, + Enter, + Win, + A, + B, + C, + D, + E, + F, + G, + H, + I, + J, + K, + L, + M, + N, + O, + P, + Q, + R, + S, + T, + U, + V, + W, + X, + Y, + Z, + Num0, + Num1, + Num2, + Num3, + Num4, + Num5, + Num6, + Num7, + Num8, + Num9, + F1, + F2, + F3, + F4, + F5, + F6, + F7, + F8, + F9, + F10, + F11, + F12, + Space, + Backspace, + Delete, + Insert, + Home, + End, + PageUp, + PageDown, + Up, + Down, + Left, + Right, + Other, + } + + /// +    /// Provides methods for simulating keyboard input. +    /// + internal static class KeyboardHelper + { + [DllImport("user32.dll")] +#pragma warning disable SA1300 // Element should begin with upper-case letter + private static extern void keybd_event(byte bVk, byte bScan, uint dwFlags, UIntPtr dwExtraInfo); +#pragma warning restore SA1300 // Element should begin with upper-case letter + +#pragma warning disable SA1310 // Field names should not contain underscore + private const byte VK_LWIN = 0x5B; + private const uint KEYEVENTF_KEYDOWN = 0x0000; + private const uint KEYEVENTF_KEYUP = 0x0002; +#pragma warning restore SA1310 // Field names should not contain underscore + + /// + /// Sends a combination of keys. + /// + /// The keys to send. + public static void SendKeys(params Key[] keys) + { + string keysToSend = string.Join(string.Empty, keys.Select(TranslateKey)); + SendWinKeyCombination(keysToSend); + } + + /// +        /// Translates a key to its corresponding SendKeys representation. +        /// +        /// The key to translate. +        /// The SendKeys representation of the key. + private static string TranslateKey(Key key) + { + switch (key) + { + case Key.Ctrl: + return "^"; + case Key.Alt: + return "%"; + case Key.Shift: + return "+"; + case Key.Tab: + return "{TAB}"; + case Key.Esc: + return "{ESC}"; + case Key.Enter: + return "{ENTER}"; + case Key.Win: + return "{WIN}"; + case Key.Space: + return " "; + case Key.Backspace: + return "{BACKSPACE}"; + case Key.Delete: + return "{DELETE}"; + case Key.Insert: + return "{INSERT}"; + case Key.Home: + return "{HOME}"; + case Key.End: + return "{END}"; + case Key.PageUp: + return "{PGUP}"; + case Key.PageDown: + return "{PGDN}"; + case Key.Up: + return "{UP}"; + case Key.Down: + return "{DOWN}"; + case Key.Left: + return "{LEFT}"; + case Key.Right: + return "{RIGHT}"; + case Key.F1: + return "{F1}"; + case Key.F2: + return "{F2}"; + case Key.F3: + return "{F3}"; + case Key.F4: + return "{F4}"; + case Key.F5: + return "{F5}"; + case Key.F6: + return "{F6}"; + case Key.F7: + return "{F7}"; + case Key.F8: + return "{F8}"; + case Key.F9: + return "{F9}"; + case Key.F10: + return "{F10}"; + case Key.F11: + return "{F11}"; + case Key.F12: + return "{F12}"; + case Key.A: + return "A"; + case Key.B: + return "B"; + case Key.C: + return "C"; + case Key.D: + return "D"; + case Key.E: + return "E"; + case Key.F: + return "F"; + case Key.G: + return "G"; + case Key.H: + return "H"; + case Key.I: + return "I"; + case Key.J: + return "J"; + case Key.K: + return "K"; + case Key.L: + return "L"; + case Key.M: + return "M"; + case Key.N: + return "N"; + case Key.O: + return "O"; + case Key.P: + return "P"; + case Key.Q: + return "Q"; + case Key.R: + return "R"; + case Key.S: + return "S"; + case Key.T: + return "T"; + case Key.U: + return "U"; + case Key.V: + return "V"; + case Key.W: + return "W"; + case Key.X: + return "X"; + case Key.Y: + return "Y"; + case Key.Z: + return "Z"; + case Key.Num0: + return "0"; + case Key.Num1: + return "1"; + case Key.Num2: + return "2"; + case Key.Num3: + return "3"; + case Key.Num4: + return "4"; + case Key.Num5: + return "5"; + case Key.Num6: + return "6"; + case Key.Num7: + return "7"; + case Key.Num8: + return "8"; + case Key.Num9: + return "9"; + default: + return string.Empty; + } + } + + /// + /// Sends a combination of keys, including the Windows key, to the system. + /// + /// The keys to send. + private static void SendWinKeyCombination(string keys) + { + bool winKeyDown = false; + + if (keys.Contains("{WIN}")) + { + keybd_event(VK_LWIN, 0, KEYEVENTF_KEYDOWN, UIntPtr.Zero); + winKeyDown = true; + keys = keys.Replace("{WIN}", string.Empty); // Remove {WIN} from the string +        } + + System.Windows.Forms.SendKeys.SendWait(keys); + +        // Release Windows key + if (winKeyDown) + { + keybd_event(VK_LWIN, 0, KEYEVENTF_KEYUP, UIntPtr.Zero); + } + } + } +} diff --git a/src/common/UITestAutomation/ModuleConfigData.cs b/src/common/UITestAutomation/ModuleConfigData.cs index ce04c13429..a64ece56f7 100644 --- a/src/common/UITestAutomation/ModuleConfigData.cs +++ b/src/common/UITestAutomation/ModuleConfigData.cs @@ -32,6 +32,47 @@ namespace Microsoft.PowerToys.UITest Runner, } + /// + /// Represents the window size for the UI test. + /// + public enum WindowSize + { + /// + /// Unspecified window size, won't make any size change + /// + UnSpecified, + + /// + /// Small window size, 640 * 480 + /// + Small, + + /// + /// Small window size, 480 * 640 + /// + Small_Vertical, + + /// + /// Medium window size, 1024 * 768 + /// + Medium, + + /// + /// Medium window size, 768 * 1024 + /// + Medium_Vertical, + + /// + /// Large window size, 1920 * 1080 + /// + Large, + + /// + /// Large window size, 1080 * 1920 + /// + Large_Vertical, + } + internal class ModuleConfigData { private Dictionary ModulePath { get; } diff --git a/src/common/UITestAutomation/MouseHelper.cs b/src/common/UITestAutomation/MouseHelper.cs new file mode 100644 index 0000000000..8215ea6790 --- /dev/null +++ b/src/common/UITestAutomation/MouseHelper.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. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.PowerToys.UITest +{ + internal static class MouseHelper + { + [StructLayout(LayoutKind.Sequential)] + public struct POINT + { + public int X; + public int Y; + } + + [DllImport("user32.dll")] + public static extern bool GetCursorPos(out POINT lpPoint); + + [DllImport("user32.dll")] + public static extern bool SetCursorPos(int x, int y); + +        /// +        /// Gets the current position of the mouse cursor as a tuple. +        /// +        /// A tuple containing the X and Y coordinates of the cursor. + public static Tuple GetMousePosition() + { + GetCursorPos(out POINT point); + return Tuple.Create(point.X, point.Y); + } + + /// +    /// Moves the mouse cursor to the specified screen coordinates. +    /// +    /// The new x-coordinate of the cursor. +    /// The new y-coordinate of the cursor. + /// Provides methods for capturing the screen with the mouse cursor. + /// + internal static class ScreenCapture + { + [DllImport("user32.dll")] + private static extern IntPtr GetDC(IntPtr hWnd); + + [DllImport("gdi32.dll")] + private static extern int GetDeviceCaps(IntPtr hdc, int nIndex); + + [DllImport("user32.dll")] + private static extern IntPtr ReleaseDC(IntPtr hWnd, IntPtr hDC); + + [DllImport("user32.dll")] + private static extern bool GetCursorInfo(out CURSORINFO pci); + + [DllImport("user32.dll")] + private static extern bool DrawIconEx(IntPtr hdc, int x, int y, IntPtr hIcon, int cx, int cy, int istepIfAniCur, IntPtr hbrFlickerFreeDraw, int diFlags); + + /// + /// Represents a point with X and Y coordinates. + /// + [StructLayout(LayoutKind.Sequential)] + public struct POINT + { + public int X; + public int Y; + } + + /// + /// Contains information about the cursor. + /// + [StructLayout(LayoutKind.Sequential)] + public struct CURSORINFO + { + /// + /// Gets or sets the size of the structure. + /// + public int CbSize; + + /// + /// Gets or sets the cursor state. + /// + public int Flags; + + /// + /// Gets or sets the handle to the cursor. + /// + public IntPtr HCursor; + + /// + /// Gets or sets the screen position of the cursor. + /// + public POINT PTScreenPos; + } + + private const int CURSORSHOWING = 0x00000001; + private const int DESKTOPHORZRES = 118; + private const int DESKTOPVERTRES = 117; + private const int DINORMAL = 0x0003; + + /// + /// Captures the screen with the mouse cursor and saves it to the specified file path. + /// + /// The file path to save the captured image. + private static void CaptureScreenWithMouse(string filePath) + { + IntPtr hdc = GetDC(IntPtr.Zero); + int screenWidth = GetDeviceCaps(hdc, DESKTOPHORZRES); + int screenHeight = GetDeviceCaps(hdc, DESKTOPVERTRES); + ReleaseDC(IntPtr.Zero, hdc); + + Rectangle bounds = new Rectangle(0, 0, screenWidth, screenHeight); + using (Bitmap bitmap = new Bitmap(bounds.Width, bounds.Height)) + { + using (System.Drawing.Graphics g = System.Drawing.Graphics.FromImage(bitmap)) + { + g.CopyFromScreen(bounds.Location, Point.Empty, bounds.Size); + + CURSORINFO cursorInfo; + cursorInfo.CbSize = Marshal.SizeOf(); + if (GetCursorInfo(out cursorInfo) && cursorInfo.Flags == CURSORSHOWING) + { + using (System.Drawing.Graphics gIcon = System.Drawing.Graphics.FromImage(bitmap)) + { + IntPtr hdcDest = gIcon.GetHdc(); + DrawIconEx(hdcDest, cursorInfo.PTScreenPos.X, cursorInfo.PTScreenPos.Y, cursorInfo.HCursor, 0, 0, 0, IntPtr.Zero, DINORMAL); + gIcon.ReleaseHdc(hdcDest); + } + } + } + + bitmap.Save(filePath, ImageFormat.Png); + } + } + + /// + /// Captures a screenshot and saves it to the specified directory. + /// + /// The directory to save the screenshot. + private static void CaptureScreenshot(string directory) + { + string filePath = Path.Combine(directory, $"screenshot_{DateTime.Now:yyyyMMdd_HHmmssfff}.png"); + CaptureScreenWithMouse(filePath); + } + + /// + /// Timer callback method to capture a screenshot. + /// + /// The state object passed to the callback method. + public static void TimerCallback(object? state) + { + string directory = (string)state!; + CaptureScreenshot(directory); + } + } +} diff --git a/src/common/UITestAutomation/Session.cs b/src/common/UITestAutomation/Session.cs index 9e5101fc75..d46d836c05 100644 --- a/src/common/UITestAutomation/Session.cs +++ b/src/common/UITestAutomation/Session.cs @@ -4,7 +4,7 @@ using System.Collections.ObjectModel; using System.Runtime.InteropServices; -using System.Xml.Linq; +using System.Text; using Microsoft.VisualStudio.TestTools.UnitTesting; using OpenQA.Selenium.Appium; using OpenQA.Selenium.Appium.Windows; @@ -17,27 +17,78 @@ namespace Microsoft.PowerToys.UITest /// public class Session { - private WindowsDriver Root { get; set; } + public WindowsDriver Root { get; set; } private WindowsDriver WindowsDriver { get; set; } - [DllImport("user32.dll")] - private static extern bool SetForegroundWindow(nint hWnd); + private const string AdministratorPrefix = "Administrator: "; - public Session(WindowsDriver root, WindowsDriver windowsDriver) + private List windowHandlers = new List(); + + private Window? MainWindow { get; set; } + + /// + /// Gets Main Window Handler + /// + public IntPtr MainWindowHandler { get; private set; } + + /// + /// Gets the RunAsAdmin flag. + /// If true, the session is running as admin. + /// If false, the session is not running as admin. + /// If null, no information is available. + /// + public bool? IsElevated { get; private set; } + + public Session(WindowsDriver root, WindowsDriver windowsDriver, PowerToysModule scope, WindowSize size) { + this.MainWindowHandler = IntPtr.Zero; this.Root = root; this.WindowsDriver = windowsDriver; + + // Attach to the scope & reset MainWindowHandler + this.Attach(scope, size); } /// - /// Finds an element by selector. + /// Cleans up the Session Exe. + /// + public void Cleanup() + { + /* + foreach (var windowHandle in this.windowHandlers) + { + if (windowHandle == IntPtr.Zero) + { + continue; + } + + try + { + var process = Process.GetProcessById((int)windowHandle); + if (process != null && !process.HasExited) + { + process.Kill(); + process.WaitForExit(); + } + } + catch + { + } + } + */ + + windowHandlers.Clear(); + } + + /// + /// Finds an Element or its derived class by selector. /// /// The class of the element, should be Element or its derived class. /// The selector to find the element. - /// The timeout in milliseconds (default is 3000). + /// The timeout in milliseconds (default is 5000). /// The found element. - public T Find(By by, int timeoutMS = 3000) + public T Find(By by, int timeoutMS = 5000) where T : Element, new() { Assert.IsNotNull(this.WindowsDriver, $"WindowsElement is null in method Find<{typeof(T).Name}> with parameters: by = {by}, timeoutMS = {timeoutMS}"); @@ -45,7 +96,7 @@ namespace Microsoft.PowerToys.UITest // leverage findAll to filter out mismatched elements var collection = this.FindAll(by, timeoutMS); - Assert.IsTrue(collection.Count > 0, $"Element not found using selector: {by}"); + Assert.IsTrue(collection.Count > 0, $"UI-Element({typeof(T).Name}) not found using selector: {by}"); return collection[0]; } @@ -55,9 +106,9 @@ namespace Microsoft.PowerToys.UITest /// /// The class of the element, should be Element or its derived class. /// The name of the element. - /// The timeout in milliseconds (default is 3000). + /// The timeout in milliseconds (default is 5000). /// The found element. - public T Find(string name, int timeoutMS = 3000) + public T Find(string name, int timeoutMS = 5000) where T : Element, new() { return this.Find(By.Name(name), timeoutMS); @@ -67,9 +118,9 @@ namespace Microsoft.PowerToys.UITest /// Shortcut for this.Find(by, timeoutMS) /// /// The selector to find the element. - /// The timeout in milliseconds (default is 3000). + /// The timeout in milliseconds (default is 5000). /// The found element. - public Element Find(By by, int timeoutMS = 3000) + public Element Find(By by, int timeoutMS = 5000) { return this.Find(by, timeoutMS); } @@ -78,21 +129,117 @@ namespace Microsoft.PowerToys.UITest /// Shortcut for this.Find(By.Name(name), timeoutMS) /// /// The name of the element. - /// The timeout in milliseconds (default is 3000). + /// The timeout in milliseconds (default is 5000). /// The found element. - public Element Find(string name, int timeoutMS = 3000) + public Element Find(string name, int timeoutMS = 5000) { return this.Find(By.Name(name), timeoutMS); } /// - /// Finds all elements by selector. + /// Has only one Element or its derived class by selector. + /// + /// The class of the element, should be Element or its derived class. + /// The name of the element. + /// The timeout in milliseconds (default is 5000). + /// True if only has one element, otherwise false. + public bool HasOne(By by, int timeoutMS = 5000) + where T : Element, new() + { + return this.FindAll(by, timeoutMS).Count == 1; + } + + /// + /// Shortcut for this.HasOne(by, timeoutMS) + /// + /// The name of the element. + /// The timeout in milliseconds (default is 5000). + /// True if only has one element, otherwise false. + public bool HasOne(By by, int timeoutMS = 5000) + { + return this.HasOne(by, timeoutMS); + } + + /// + /// Shortcut for this.HasOne(By.Name(name), timeoutMS) + /// + /// The class of the element, should be Element or its derived class. + /// The name of the element. + /// The timeout in milliseconds (default is 5000). + /// True if only has one element, otherwise false. + public bool HasOne(string name, int timeoutMS = 5000) + where T : Element, new() + { + return this.HasOne(By.Name(name), timeoutMS); + } + + /// + /// Shortcut for this.HasOne(name, timeoutMS) + /// + /// The name of the element. + /// The timeout in milliseconds (default is 5000). + /// True if only has one element, otherwise false. + public bool HasOne(string name, int timeoutMS = 5000) + { + return this.HasOne(By.Name(name), timeoutMS); + } + + /// + /// Has one or more Element or its derived class by selector. + /// + /// The class of the element, should be Element or its derived class. + /// The selector to find the element. + /// The timeout in milliseconds (default is 5000). + /// True if has one or more element, otherwise false. + public bool Has(By by, int timeoutMS = 5000) + where T : Element, new() + { + return this.FindAll(by, timeoutMS).Count >= 1; + } + + /// + /// Shortcut for this.Has(by, timeoutMS) + /// + /// The selector to find the element. + /// The timeout in milliseconds (default is 5000). + /// True if has one or more element, otherwise false. + public bool Has(By by, int timeoutMS = 5000) + { + return this.Has(by, timeoutMS); + } + + /// + /// Shortcut for this.Has(By.Name(name), timeoutMS) + /// + /// The class of the element, should be Element or its derived class. + /// The name of the element. + /// The timeout in milliseconds (default is 5000). + /// True if has one or more element, otherwise false. + public bool Has(string name, int timeoutMS = 5000) + where T : Element, new() + { + return this.Has(By.Name(name), timeoutMS); + } + + /// + /// Shortcut for this.Has(name, timeoutMS) + /// + /// The name of the element. + /// The timeout in milliseconds (default is 5000). + /// True if has one or more element, otherwise false. + public bool Has(string name, int timeoutMS = 5000) + { + return this.Has(name, timeoutMS); + } + + /// + /// Finds all Element or its derived class by selector. /// /// The class of the elements, should be Element or its derived class. /// The selector to find the elements. - /// The timeout in milliseconds (default is 3000). + /// The timeout in milliseconds (default is 5000). /// A read-only collection of the found elements. - public ReadOnlyCollection FindAll(By by, int timeoutMS = 3000) + public ReadOnlyCollection FindAll(By by, int timeoutMS = 5000) where T : Element, new() { Assert.IsNotNull(this.WindowsDriver, $"WindowsElement is null in method FindAll<{typeof(T).Name}> with parameters: by = {by}, timeoutMS = {timeoutMS}"); @@ -122,9 +269,9 @@ namespace Microsoft.PowerToys.UITest /// /// The class of the elements, should be Element or its derived class. /// The name to find the elements. - /// The timeout in milliseconds (default is 3000). + /// The timeout in milliseconds (default is 5000). /// A read-only collection of the found elements. - public ReadOnlyCollection FindAll(string name, int timeoutMS = 3000) + public ReadOnlyCollection FindAll(string name, int timeoutMS = 5000) where T : Element, new() { return this.FindAll(By.Name(name), timeoutMS); @@ -135,9 +282,9 @@ namespace Microsoft.PowerToys.UITest /// Shortcut for this.FindAll(by, timeoutMS) /// /// The selector to find the elements. - /// The timeout in milliseconds (default is 3000). + /// The timeout in milliseconds (default is 5000). /// A read-only collection of the found elements. - public ReadOnlyCollection FindAll(By by, int timeoutMS = 3000) + public ReadOnlyCollection FindAll(By by, int timeoutMS = 5000) { return this.FindAll(by, timeoutMS); } @@ -147,55 +294,170 @@ namespace Microsoft.PowerToys.UITest /// Shortcut for this.FindAll(By.Name(name), timeoutMS) /// /// The name to find the elements. - /// The timeout in milliseconds (default is 3000). + /// The timeout in milliseconds (default is 5000). /// A read-only collection of the found elements. - public ReadOnlyCollection FindAll(string name, int timeoutMS = 3000) + public ReadOnlyCollection FindAll(string name, int timeoutMS = 5000) { return this.FindAll(By.Name(name), timeoutMS); } /// - /// Keyboard Action key. + /// Sets the main window size. /// - /// The Keys1 to click. - /// The Keys2 to click. - /// The Keys3 to click. - /// The Keys4 to click. - public void KeyboardAction(string key1, string key2 = "", string key3 = "", string key4 = "") + /// WindowSize enum + public void SetMainWindowSize(WindowSize size) { - PerformAction((actions, windowElement) => + if (size == WindowSize.UnSpecified) { - if (string.IsNullOrEmpty(key2)) - { - actions.SendKeys(key1); - } - else if (string.IsNullOrEmpty(key3)) - { - actions.SendKeys(key1).SendKeys(key2); - } - else if (string.IsNullOrEmpty(key4)) - { - actions.SendKeys(key1).SendKeys(key2).SendKeys(key3); - } - else - { - actions.SendKeys(key1).SendKeys(key2).SendKeys(key3).SendKeys(key4); - } + return; + } - actions.Release(); - actions.Build().Perform(); + int width = 0, height = 0; + + switch (size) + { + case WindowSize.Small: + width = 640; + height = 480; + break; + case WindowSize.Small_Vertical: + width = 480; + height = 640; + break; + case WindowSize.Medium: + width = 1024; + height = 768; + break; + case WindowSize.Medium_Vertical: + width = 768; + height = 1024; + break; + case WindowSize.Large: + width = 1920; + height = 1080; + break; + case WindowSize.Large_Vertical: + width = 1080; + height = 1920; + break; + } + + if (width > 0 && height > 0) + { + this.SetMainWindowSize(width, height); + } + } + + /// + /// Sets the main window size based on Width and Height. + /// + /// the width in pixel + /// the height in pixel + public void SetMainWindowSize(int width, int height) + { + if (this.MainWindowHandler == IntPtr.Zero + || width <= 0 + || height <= 0) + { + return; + } + + ApiHelper.SetWindowPos(this.MainWindowHandler, IntPtr.Zero, 0, 0, width, height, ApiHelper.SetWindowPosNoMove | ApiHelper.SetWindowPosNoZorder | ApiHelper.SetWindowPosShowWindow); + + // Wait for 1000ms after resize + Task.Delay(1000).Wait(); + } + + /// + /// Close the main window. + /// + public void CloseMainWindow() + { + if (MainWindow != null) + { + MainWindow.Close(); + MainWindow = null; + } + } + + /// + /// Retrieves the color of the pixel at the specified screen coordinates. + /// + /// The X coordinate on the screen. + /// The Y coordinate on the screen. + /// The color of the pixel at the specified coordinates. + public Color GetPixelColor(int x, int y) + { + IntPtr hdc = ApiHelper.GetDC(IntPtr.Zero); + uint pixel = ApiHelper.GetPixel(hdc, x, y); + _ = ApiHelper.ReleaseDC(IntPtr.Zero, hdc); + + int r = (int)(pixel & 0x000000FF); + int g = (int)((pixel & 0x0000FF00) >> 8); + int b = (int)((pixel & 0x00FF0000) >> 16); + + return Color.FromArgb(r, g, b); + } + + /// + /// Sends a combination of keys. + /// + /// The keys to send. + public void SendKeys(params Key[] keys) + { + PerformAction(() => + { + KeyboardHelper.SendKeys(keys); }); } + /// + /// Sends a sequence of keys. + /// + /// An array of keys to send. + public void SendKeySequence(params Key[] keys) + { + PerformAction(() => + { + foreach (var key in keys) + { + KeyboardHelper.SendKeys(key); + } + }); + } + +        /// +        /// Gets the current position of the mouse cursor as a tuple. +        /// +        /// A tuple containing the X and Y coordinates of the cursor. + public Tuple GetMousePosition() + { + return MouseHelper.GetMousePosition(); + } + + /// +    /// Moves the mouse cursor to the specified screen coordinates. +    /// +    /// The new x-coordinate of the cursor. +    /// The new y-coordinate of the cursor. + { + MouseHelper.MoveMouseTo(x, y); + }); + } + /// /// Attaches to an existing PowerToys module. /// /// The PowerToys module to attach to. + /// The window size to set. Default is no change to window size /// The attached session. - public Session Attach(PowerToysModule module) + public Session Attach(PowerToysModule module, WindowSize size = WindowSize.UnSpecified) { string windowName = ModuleConfigData.Instance.GetModuleWindowName(module); - return this.Attach(windowName); + return this.Attach(windowName, size); } /// @@ -203,26 +465,44 @@ namespace Microsoft.PowerToys.UITest /// The session should be attached when a new app is started. /// /// The window name to attach to. + /// The window size to set. Default is no change to window size /// The attached session. - public Session Attach(string windowName) + public Session Attach(string windowName, WindowSize size = WindowSize.UnSpecified) { + this.IsElevated = null; + this.MainWindowHandler = IntPtr.Zero; + if (this.Root != null) { - var window = this.Root.FindElementByName(windowName); - Assert.IsNotNull(window, $"Failed to attach. Window '{windowName}' not found"); + // search window handler by window title (admin and non-admin titles) + var matchingWindows = ApiHelper.FindDesktopWindowHandler([windowName, AdministratorPrefix + windowName]); + if (matchingWindows.Count == 0 || matchingWindows[0].HWnd == IntPtr.Zero) + { + Assert.Fail($"Failed to attach. Window '{windowName}' not found"); + } + + // pick one from matching windows + this.MainWindowHandler = matchingWindows[0].HWnd; + this.IsElevated = matchingWindows[0].Title.StartsWith(AdministratorPrefix); + + ApiHelper.SetForegroundWindow(this.MainWindowHandler); + + var hexWindowHandle = this.MainWindowHandler.ToInt64().ToString("x"); - var windowHandle = new nint(int.Parse(window.GetAttribute("NativeWindowHandle"))); - SetForegroundWindow(windowHandle); - var hexWindowHandle = windowHandle.ToString("x"); var appCapabilities = new AppiumOptions(); - appCapabilities.AddAdditionalCapability("appTopLevelWindow", hexWindowHandle); appCapabilities.AddAdditionalCapability("deviceName", "WindowsPC"); this.WindowsDriver = new WindowsDriver(new Uri(ModuleConfigData.Instance.GetWindowsApplicationDriverUrl()), appCapabilities); - Assert.IsNotNull(this.WindowsDriver, "Attach WindowsDriver is null"); - // Set implicit timeout to make element search retry every 500 ms - this.WindowsDriver.Manage().Timeouts().ImplicitWait = TimeSpan.FromSeconds(3); + this.windowHandlers.Add(this.MainWindowHandler); + + if (size != WindowSize.UnSpecified) + { + this.SetMainWindowSize(size); + } + + // Set MainWindow + MainWindow = Find(matchingWindows[0].Title); } else { @@ -232,6 +512,71 @@ namespace Microsoft.PowerToys.UITest return this; } + private static class ApiHelper + { + [DllImport("user32.dll")] + public static extern bool SetForegroundWindow(IntPtr hWnd); + + public const uint SetWindowPosNoMove = 0x0002; + public const uint SetWindowPosNoZorder = 0x0004; + public const uint SetWindowPosShowWindow = 0x0040; + + [DllImport("user32.dll", SetLastError = true)] + public static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int x, int y, int cx, int cy, uint uFlags); + + // Delegate for the EnumWindows callback function + private delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam); + + // P/Invoke declaration for EnumWindows + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam); + + // P/Invoke declaration for GetWindowTextLength + [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Auto)] + private static extern int GetWindowTextLength(IntPtr hWnd); + + // P/Invoke declaration for GetWindowText + [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Auto)] + private static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount); + + [DllImport("user32.dll")] + public static extern IntPtr GetDC(IntPtr hWnd); + + [DllImport("gdi32.dll")] + public static extern uint GetPixel(IntPtr hdc, int x, int y); + + [DllImport("user32.dll")] + public static extern int ReleaseDC(IntPtr hWnd, IntPtr hDC); + + public static List<(IntPtr HWnd, string Title)> FindDesktopWindowHandler(string[] matchingWindowsTitles) + { + var windows = new List<(IntPtr HWnd, string Title)>(); + + _ = EnumWindows( + (hWnd, lParam) => + { + int length = GetWindowTextLength(hWnd); + if (length > 0) + { + var builder = new StringBuilder(length + 1); + _ = GetWindowText(hWnd, builder, builder.Capacity); + + var title = builder.ToString(); + if (matchingWindowsTitles.Contains(title)) + { + windows.Add((hWnd, title)); + } + } + + return true; // Continue enumeration + }, + IntPtr.Zero); + + return windows; + } + } + /// /// Simulates a manual operation on the element. /// @@ -254,5 +599,26 @@ namespace Microsoft.PowerToys.UITest Task.Delay(msPostAction).Wait(); } } + + /// + /// Simulates a manual operation on the element. + /// + /// The action to perform on the element. + /// The number of milliseconds to wait before the action. Default value is 500 ms + /// The number of milliseconds to wait after the action. Default value is 500 ms + protected void PerformAction(Action action, int msPreAction = 500, int msPostAction = 500) + { + if (msPreAction > 0) + { + Task.Delay(msPreAction).Wait(); + } + + action(); + + if (msPostAction > 0) + { + Task.Delay(msPostAction).Wait(); + } + } } } diff --git a/src/common/UITestAutomation/SessionHelper.cs b/src/common/UITestAutomation/SessionHelper.cs index 3fa17a8e63..1ed58a4b6e 100644 --- a/src/common/UITestAutomation/SessionHelper.cs +++ b/src/common/UITestAutomation/SessionHelper.cs @@ -43,6 +43,7 @@ namespace Microsoft.PowerToys.UITest Verb = "runas", }; + this.ExitExe(winAppDriverProcessInfo.FileName); this.appDriver = Process.Start(winAppDriverProcessInfo); var runnerProcessInfo = new ProcessStartInfo @@ -56,9 +57,6 @@ namespace Microsoft.PowerToys.UITest var desktopCapabilities = new AppiumOptions(); desktopCapabilities.AddAdditionalCapability("app", "Root"); this.Root = new WindowsDriver(new Uri(ModuleConfigData.Instance.GetWindowsApplicationDriverUrl()), desktopCapabilities); - - // Set default timeout to 5 seconds - this.Root.Manage().Timeouts().ImplicitWait = TimeSpan.FromSeconds(5); } /// @@ -67,7 +65,8 @@ namespace Microsoft.PowerToys.UITest /// The PowerToys module to start. public SessionHelper Init() { - this.StartExe(locationPath + this.sessionPath); + this.ExitExe(this.locationPath + this.sessionPath); + this.StartExe(this.locationPath + this.sessionPath); Assert.IsNotNull(this.Driver, $"Failed to initialize the test environment. Driver is null."); @@ -94,29 +93,14 @@ namespace Microsoft.PowerToys.UITest } } - /// - /// Starts a new exe and takes control of it. - /// - /// The path to the application executable. - public void StartExe(string appPath) - { - var opts = new AppiumOptions(); - opts.AddAdditionalCapability("app", appPath); - Console.WriteLine($"appPath: {appPath}"); - this.Driver = new WindowsDriver(new Uri(ModuleConfigData.Instance.GetWindowsApplicationDriverUrl()), opts); - - // Set default timeout to 5 seconds - this.Driver.Manage().Timeouts().ImplicitWait = TimeSpan.FromSeconds(5); - } - /// /// Exit a exe. /// - /// The path to the application executable. - public void ExitExe(string path) + /// The path to the application executable. + public void ExitExe(string appPath) { // Exit Exe - string exeName = Path.GetFileNameWithoutExtension(path); + string exeName = Path.GetFileNameWithoutExtension(appPath); // PowerToys.FancyZonesEditor Process[] processes = Process.GetProcessesByName(exeName); @@ -134,6 +118,21 @@ namespace Microsoft.PowerToys.UITest } } + /// + /// Starts a new exe and takes control of it. + /// + /// The path to the application executable. + public void StartExe(string appPath) + { + var opts = new AppiumOptions(); + opts.AddAdditionalCapability("app", appPath); + opts.AddAdditionalCapability("ms:waitForAppLaunch", "5"); + this.Driver = new WindowsDriver(new Uri(ModuleConfigData.Instance.GetWindowsApplicationDriverUrl()), opts); + + // Set default timeout to 5 seconds + this.Driver.Manage().Timeouts().ImplicitWait = TimeSpan.FromSeconds(5); + } + /// /// Exit now exe. /// diff --git a/src/common/UITestAutomation/UITestAutomation.csproj b/src/common/UITestAutomation/UITestAutomation.csproj index 0da17d85b8..09d1adeb6f 100644 --- a/src/common/UITestAutomation/UITestAutomation.csproj +++ b/src/common/UITestAutomation/UITestAutomation.csproj @@ -8,6 +8,9 @@ enable true true + net9.0-windows10.0.22621.0 + true + false diff --git a/src/common/UITestAutomation/UITestBase.cs b/src/common/UITestAutomation/UITestBase.cs index 40de0dc991..76606ec918 100644 --- a/src/common/UITestAutomation/UITestBase.cs +++ b/src/common/UITestAutomation/UITestBase.cs @@ -8,6 +8,7 @@ using System.Diagnostics.CodeAnalysis; using System.Reflection; using System.Xml.Linq; using Microsoft.VisualStudio.TestTools.UnitTesting; +using OpenQA.Selenium; using OpenQA.Selenium.Appium; using OpenQA.Selenium.Appium.Windows; @@ -17,19 +18,28 @@ namespace Microsoft.PowerToys.UITest /// Base class that should be inherited by all Test Classes. /// [TestClass] - public class UITestBase + public class UITestBase : IDisposable { - public Session Session { get; set; } + public required TestContext TestContext { get; set; } - private readonly SessionHelper sessionHelper; + public required Session Session { get; set; } + private readonly bool isInPipeline; private readonly PowerToysModule scope; + private readonly WindowSize size; + private SessionHelper? sessionHelper; + private System.Threading.Timer? screenshotTimer; + private string? screenshotDirectory; - public UITestBase(PowerToysModule scope = PowerToysModule.PowerToysSettings) + // private System.Threading.Timer? screenshotTimer; + // private string? screenshotDirectory; + public UITestBase(PowerToysModule scope = PowerToysModule.PowerToysSettings, WindowSize size = WindowSize.UnSpecified) { this.scope = scope; + this.size = size; this.sessionHelper = new SessionHelper(scope).Init(); - this.Session = new Session(this.sessionHelper.GetRoot(), this.sessionHelper.GetDriver()); + this.Session = new Session(this.sessionHelper.GetRoot(), this.sessionHelper.GetDriver(), scope, size); + this.isInPipeline = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("buildPlatforms")); } /// @@ -38,6 +48,24 @@ namespace Microsoft.PowerToys.UITest [TestInitialize] public void TestInit() { + // Environment setup for pipeline: + // 1.Escape Popups + // 2.Continuous screenshots + if (isInPipeline) + { + screenshotDirectory = Path.Combine(this.TestContext.TestResultsDirectory ?? string.Empty, "UITestScreenshots_" + Guid.NewGuid().ToString()); + Directory.CreateDirectory(screenshotDirectory); + + // Take screenshot every 1 second + screenshotTimer = new System.Threading.Timer(ScreenCapture.TimerCallback, screenshotDirectory, TimeSpan.Zero, TimeSpan.FromMilliseconds(1000)); + + // Escape Popups before starting + this.SendKeys(Key.Esc); + } + + this.sessionHelper = new SessionHelper(scope).Init(); + this.Session = new Session(this.sessionHelper.GetRoot(), this.sessionHelper.GetDriver(), scope, size); + if (this.scope == PowerToysModule.PowerToysSettings) { // close Debug warning dialog if any @@ -50,12 +78,32 @@ namespace Microsoft.PowerToys.UITest } /// - /// UnInitializes the test. + /// Cleanups the test. /// [TestCleanup] - public void TestClean() + public void TestCleanup() { - this.sessionHelper.Cleanup(); + if (isInPipeline) + { + screenshotTimer?.Change(Timeout.Infinite, Timeout.Infinite); + Dispose(); + if (TestContext.CurrentTestOutcome is UnitTestOutcome.Failed + or UnitTestOutcome.Error + or UnitTestOutcome.Unknown) + { + Task.Delay(1000).Wait(); + AddScreenShotsToTestResultsDirectory(); + } + } + + this.Session.Cleanup(); + this.sessionHelper!.Cleanup(); + } + + public void Dispose() + { + screenshotTimer?.Dispose(); + GC.SuppressFinalize(this); } /// @@ -64,22 +112,22 @@ namespace Microsoft.PowerToys.UITest /// /// The class of the element, should be Element or its derived class. /// The selector to find the element. - /// The timeout in milliseconds (default is 3000). + /// The timeout in milliseconds (default is 5000). /// The found element. - protected T Find(By by, int timeoutMS = 3000) + protected T Find(By by, int timeoutMS = 5000) where T : Element, new() { return this.Session.Find(by, timeoutMS); } /// - /// Shortcut for this.Session.Find(By.Name(name), timeoutMS) + /// Shortcut for this.Session.Find(name, timeoutMS) /// /// The class of the element, should be Element or its derived class. /// The name of the element. - /// The timeout in milliseconds (default is 3000). + /// The timeout in milliseconds (default is 5000). /// The found element. - protected T Find(string name, int timeoutMS = 3000) + protected T Find(string name, int timeoutMS = 5000) where T : Element, new() { return this.Session.Find(By.Name(name), timeoutMS); @@ -89,33 +137,129 @@ namespace Microsoft.PowerToys.UITest /// Shortcut for this.Session.Find(by, timeoutMS) /// /// The selector to find the element. - /// The timeout in milliseconds (default is 3000). + /// The timeout in milliseconds (default is 5000). /// The found element. - protected Element Find(By by, int timeoutMS = 3000) + protected Element Find(By by, int timeoutMS = 5000) { return this.Session.Find(by, timeoutMS); } /// - /// Shortcut for this.Session.Find(By.Name(name), timeoutMS) + /// Shortcut for this.Session.Find(name, timeoutMS) /// /// The name of the element. - /// The timeout in milliseconds (default is 3000). + /// The timeout in milliseconds (default is 5000). /// The found element. - protected Element Find(string name, int timeoutMS = 3000) + protected Element Find(string name, int timeoutMS = 5000) { return this.Session.Find(name, timeoutMS); } + /// + /// Has only one Element or its derived class by selector. + /// + /// The class of the element, should be Element or its derived class. + /// The name of the element. + /// The timeout in milliseconds (default is 5000). + /// True if only has one element, otherwise false. + public bool HasOne(By by, int timeoutMS = 5000) + where T : Element, new() + { + return this.FindAll(by, timeoutMS).Count == 1; + } + + /// + /// Shortcut for this.Session.HasOne(by, timeoutMS) + /// + /// The name of the element. + /// The timeout in milliseconds (default is 5000). + /// True if only has one element, otherwise false. + public bool HasOne(By by, int timeoutMS = 5000) + { + return this.Session.HasOne(by, timeoutMS); + } + + /// + /// Shortcut for this.Session.HasOne(name, timeoutMS) + /// + /// The class of the element, should be Element or its derived class. + /// The name of the element. + /// The timeout in milliseconds (default is 5000). + /// True if only has one element, otherwise false. + public bool HasOne(string name, int timeoutMS = 5000) + where T : Element, new() + { + return this.Session.HasOne(By.Name(name), timeoutMS); + } + + /// + /// Shortcut for this.Session.HasOne(name, timeoutMS) + /// + /// The name of the element. + /// The timeout in milliseconds (default is 5000). + /// True if only has one element, otherwise false. + public bool HasOne(string name, int timeoutMS = 5000) + { + return this.Session.HasOne(name, timeoutMS); + } + + /// + /// Shortcut for this.Session.Has(by, timeoutMS) + /// + /// The class of the element, should be Element or its derived class. + /// The selector to find the element. + /// The timeout in milliseconds (default is 5000). + /// True if has one or more element, otherwise false. + public bool Has(By by, int timeoutMS = 5000) + where T : Element, new() + { + return this.Session.FindAll(by, timeoutMS).Count >= 1; + } + + /// + /// Shortcut for this.Session.Has(by, timeoutMS) + /// + /// The selector to find the element. + /// The timeout in milliseconds (default is 5000). + /// True if has one or more element, otherwise false. + public bool Has(By by, int timeoutMS = 5000) + { + return this.Session.Has(by, timeoutMS); + } + + /// + /// Shortcut for this.Session.Has(By.Name(name), timeoutMS) + /// + /// The class of the element, should be Element or its derived class. + /// The name of the element. + /// The timeout in milliseconds (default is 5000). + /// True if has one or more element, otherwise false. + public bool Has(string name, int timeoutMS = 5000) + where T : Element, new() + { + return this.Session.Has(By.Name(name), timeoutMS); + } + + /// + /// Shortcut for this.Session.Has(name, timeoutMS) + /// + /// The name of the element. + /// The timeout in milliseconds (default is 5000). + /// True if has one or more element, otherwise false. + public bool Has(string name, int timeoutMS = 5000) + { + return this.Session.Has(name, timeoutMS); + } + /// /// Finds all elements by selector. /// Shortcut for this.Session.FindAll(by, timeoutMS) /// /// The class of the elements, should be Element or its derived class. /// The selector to find the elements. - /// The timeout in milliseconds (default is 3000). + /// The timeout in milliseconds (default is 5000). /// A read-only collection of the found elements. - protected ReadOnlyCollection FindAll(By by, int timeoutMS = 3000) + protected ReadOnlyCollection FindAll(By by, int timeoutMS = 5000) where T : Element, new() { return this.Session.FindAll(by, timeoutMS); @@ -127,9 +271,9 @@ namespace Microsoft.PowerToys.UITest /// /// The class of the elements, should be Element or its derived class. /// The name of the elements. - /// The timeout in milliseconds (default is 3000). + /// The timeout in milliseconds (default is 5000). /// A read-only collection of the found elements. - protected ReadOnlyCollection FindAll(string name, int timeoutMS = 3000) + protected ReadOnlyCollection FindAll(string name, int timeoutMS = 5000) where T : Element, new() { return this.Session.FindAll(By.Name(name), timeoutMS); @@ -140,9 +284,9 @@ namespace Microsoft.PowerToys.UITest /// Shortcut for this.Session.FindAll(by, timeoutMS) /// /// The selector to find the elements. - /// The timeout in milliseconds (default is 3000). + /// The timeout in milliseconds (default is 5000). /// A read-only collection of the found elements. - protected ReadOnlyCollection FindAll(By by, int timeoutMS = 3000) + protected ReadOnlyCollection FindAll(By by, int timeoutMS = 5000) { return this.Session.FindAll(by, timeoutMS); } @@ -152,20 +296,94 @@ namespace Microsoft.PowerToys.UITest /// Shortcut for this.Session.FindAll(By.Name(name), timeoutMS) /// /// The name of the elements. - /// The timeout in milliseconds (default is 3000). + /// The timeout in milliseconds (default is 5000). /// A read-only collection of the found elements. - protected ReadOnlyCollection FindAll(string name, int timeoutMS = 3000) + protected ReadOnlyCollection FindAll(string name, int timeoutMS = 5000) { return this.Session.FindAll(By.Name(name), timeoutMS); } + /// + /// Captures the last screenshot when the test fails. + /// + protected void CaptureLastScreenshot() + { + // Implement your screenshot capture logic here + // For example, save a screenshot to a file and return the file path + string screenshotPath = Path.Combine(this.TestContext.TestResultsDirectory ?? string.Empty, "last_screenshot.png"); + + this.Session.Root.GetScreenshot().SaveAsFile(screenshotPath, ScreenshotImageFormat.Png); + + // Save screenshot to screenshotPath & upload to test attachment + this.TestContext.AddResultFile(screenshotPath); + } + + /// + /// Retrieves the color of the pixel at the specified screen coordinates. + /// + /// The X coordinate on the screen. + /// The Y coordinate on the screen. + /// The color of the pixel at the specified coordinates. + public Color GetPixelColor(int x, int y) + { + return this.Session.GetPixelColor(x, y); + } + + /// + /// Sends a combination of keys. + /// + /// The keys to send. + public void SendKeys(params Key[] keys) + { + this.Session.SendKeys(keys); + } + + /// + /// Sends a sequence of keys. + /// + /// An array of keys to send. + public void SendKeySequence(params Key[] keys) + { + this.Session.SendKeySequence(keys); + } + + /// +        /// Gets the current position of the mouse cursor as a tuple. +        /// +        /// A tuple containing the X and Y coordinates of the cursor. + public Tuple GetMousePosition() + { + return this.Session.GetMousePosition(); + } + + /// +    /// Moves the mouse cursor to the specified screen coordinates. +    /// +    /// The new x-coordinate of the cursor. +    /// The new y-coordinate of the cursor. /// Restart scope exe. /// public void RestartScopeExe() { - this.sessionHelper.RestartScopeExe(); - this.Session = new Session(this.sessionHelper.GetRoot(), this.sessionHelper.GetDriver()); + this.sessionHelper!.RestartScopeExe(); + this.Session = new Session(this.sessionHelper.GetRoot(), this.sessionHelper.GetDriver(), this.scope, this.size); return; } @@ -174,7 +392,7 @@ namespace Microsoft.PowerToys.UITest /// public void ExitScopeExe() { - this.sessionHelper.ExitScopeExe(); + this.sessionHelper!.ExitScopeExe(); return; } } diff --git a/src/common/UITestAutomation/VisualAssert.cs b/src/common/UITestAutomation/VisualAssert.cs new file mode 100644 index 0000000000..280740daca --- /dev/null +++ b/src/common/UITestAutomation/VisualAssert.cs @@ -0,0 +1,144 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Drawing; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.PowerToys.UITest +{ + public static class VisualAssert + { + /// + /// Asserts current visual state of the element is equal with base line image. + /// To use this VisualAssert, you need to set Window Theme to Light-Mode to avoid Theme color difference in baseline image. + /// Such limiation could be removed either Auto-generate baseline image for both Light & Dark mode + /// + /// TestContext object + /// Element object + /// additional scenario name if two or more scenarios in one test + [RequiresUnreferencedCode("This method uses reflection which may not be compatible with trimming.")] + public static void AreEqual(TestContext? testContext, Element element, System.Reflection.Assembly callerAssembly, string scenarioSubname = "") + { + if (element == null) + { + Assert.Fail("Element object is null or invalid"); + } + + var stackTrace = new StackTrace(); + var callerFrame = stackTrace.GetFrame(1); + var callerMethod = callerFrame?.GetMethod(); + + var callerName = callerMethod?.Name; + var callerClassName = callerMethod?.DeclaringType?.Name; + + if (string.IsNullOrEmpty(callerName) || string.IsNullOrEmpty(callerClassName)) + { + Assert.Fail("Unable to determine the caller method and class name."); + } + + if (string.IsNullOrWhiteSpace(scenarioSubname)) + { + scenarioSubname = string.Join("_", callerClassName, callerName); + } + else + { + scenarioSubname = string.Join("_", callerClassName, callerName, scenarioSubname.Trim()); + } + + var baselineImageResourceName = callerMethod!.DeclaringType!.Assembly.GetManifestResourceNames().Where(name => name.Contains(scenarioSubname)).FirstOrDefault(); + + var tempTestImagePath = GetTempFilePath(scenarioSubname, "test", ".png"); + + // Save the image with the user preference color erased + element.SaveToPngFile(tempTestImagePath, true); + + if (string.IsNullOrEmpty(baselineImageResourceName) + || !Path.GetFileNameWithoutExtension(baselineImageResourceName).EndsWith(scenarioSubname)) + { + Assert.Fail($"Baseline image for scenario {scenarioSubname} can not be found, test image saved in file://{tempTestImagePath.Replace('\\', '/')}"); + } + + var tempBaselineImagePath = GetTempFilePath(scenarioSubname, "baseline", Path.GetExtension(baselineImageResourceName)); + + bool isSame = false; + +#pragma warning disable CS8604 // Possible null reference argument. + using (var baselineImage = new Bitmap(callerMethod!.DeclaringType!.Assembly.GetManifestResourceStream(baselineImageResourceName))) + { + using (var testImage = new Bitmap(tempTestImagePath)) + { + isSame = VisualAssert.AreEqual(baselineImage, testImage); + + if (!isSame) + { + // Copy baseline image to temp folder as well + baselineImage.Save(tempBaselineImagePath); + } + } + } +#pragma warning restore CS8604 // Possible null reference argument. + + if (!isSame) + { + if (testContext != null) + { + testContext.AddResultFile(tempBaselineImagePath); + testContext.AddResultFile(tempTestImagePath); + } + + Assert.Fail($"Fail to validate visual result for scenario {scenarioSubname}, baseline image can be found file://{tempBaselineImagePath.Replace('\\', '/')}, and test image can be found file://{tempTestImagePath.Replace('\\', '/')}"); + } + } + + /// + /// Get temp file path + /// + /// scenario name + /// baseline or test image + /// image file extension + /// full temp file path + private static string GetTempFilePath(string scenario, string imageType, string extension) + { + var tempFileFullName = $"{scenario}_{imageType}{extension}"; + + // Remove invalid filename character if any + Path.GetInvalidFileNameChars().ToList().ForEach(c => tempFileFullName = tempFileFullName.Replace(c, '-')); + + return Path.Combine(Path.GetTempPath(), tempFileFullName); + } + + /// + /// Test if two images are equal bit-by-bit + /// + /// baseline image + /// test image + /// true if are equal,otherwise false + private static bool AreEqual(Bitmap baselineImage, Bitmap testImage) + { + if (baselineImage.Width != testImage.Width || baselineImage.Height != testImage.Height) + { + return false; + } + + // WinAppDriver sometimes adds a border to the screenshot (around 2 pix width), and it is not always consistent. + // So we exclude the border when comparing the images, and usually it is the edge of the windows, won't affect the comparison. + int excludeBorderWidth = 5, excludeBorderHeight = 5; + + for (int x = excludeBorderWidth; x < baselineImage.Width - excludeBorderWidth; x++) + { + for (int y = excludeBorderHeight; y < baselineImage.Height - excludeBorderHeight; y++) + { + if (!VisualHelper.PixIsSame(baselineImage.GetPixel(x, y), testImage.GetPixel(x, y))) + { + return false; + } + } + } + + return true; + } + } +} diff --git a/src/common/UITestAutomation/VisualHelper.cs b/src/common/UITestAutomation/VisualHelper.cs new file mode 100644 index 0000000000..767ce29d97 --- /dev/null +++ b/src/common/UITestAutomation/VisualHelper.cs @@ -0,0 +1,170 @@ +// 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; +using System.Collections.Generic; +using System.Drawing; +using System.Drawing.Imaging; +using System.Linq; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.PowerToys.UITest +{ + internal static class VisualHelper + { + #pragma warning disable SA1307 + [StructLayout(LayoutKind.Sequential)] + private struct IMMERSIVE_COLOR_PREFERENCE + { + public uint dwColorSetIndex; + public uint crStartColor; + public uint crAccentColor; + } + #pragma warning restore SA1307 + + [DllImport("uxtheme.dll", EntryPoint = "#120")] + private static extern IntPtr GetUserColorPreference(ref IMMERSIVE_COLOR_PREFERENCE pcpPreference, bool fForceReload); + + /// + /// Gets the system accent color. + /// + /// The system accent color as a Color object. + private static Color GetSystemAccentColor() + { + IMMERSIVE_COLOR_PREFERENCE colorPreference = default(IMMERSIVE_COLOR_PREFERENCE); + GetUserColorPreference(ref colorPreference, true); + return ToColor(colorPreference.crStartColor); + } + + /// + /// Converts a color value to a Color object. + /// + /// The color value. + /// The Color object. + private static Color ToColor(uint c) + { + int r = (int)(c & 0xFF) % 256; + int g = (int)((c >> 8) & 0xFF) % 256; + int b = (int)(c >> 16) % 256; + return Color.FromArgb(r, g, b); + } + + /// + /// Gets HSL values from a Color object. + /// + /// The Color object. + /// A tuple containing the HSL values. + private static (double H, double S, double L) GetHSL(Color color) + { + double rNorm = color.R / 255.0; + double gNorm = color.G / 255.0; + double bNorm = color.B / 255.0; + + double max = Math.Max(rNorm, Math.Max(gNorm, bNorm)); + double min = Math.Min(rNorm, Math.Min(gNorm, bNorm)); + double h = 0, s = 0, l = (max + min) / 2; + + if (max != min) + { + double delta = max - min; + s = l > 0.5 ? delta / (2 - max - min) : delta / (max + min); + + if (max == rNorm) + { + h = ((gNorm - bNorm) / delta) + (gNorm < bNorm ? 6 : 0); + } + else if (max == gNorm) + { + h = ((bNorm - rNorm) / delta) + 2; + } + else if (max == bNorm) + { + h = ((rNorm - gNorm) / delta) + 4; + } + + h /= 6; + } + + return (h * 360, s * 100, l * 100); + } + + /// + /// Makes a specific color in an image transparent. + /// + /// The path to the image file. + /// The path to save the output image file. + /// The target color to make transparent. + /// The fuzz factor for color comparison, default is 2. + private static void MakeColorTransparent(string imagePath, string outputPath, Color targetColor, int fuzz = 2) + { + var hsl = GetHSL(targetColor); + + // Assert.IsNotNull(null, $"Target Color - H: {hsl.H}, S: {hsl.S}, L: {hsl.L}"); + using (Bitmap originalBitmap = new Bitmap(imagePath)) + { + using (Bitmap bitmap = new Bitmap(originalBitmap)) + { + for (int y = 0; y < bitmap.Height; y++) + { + for (int x = 0; x < bitmap.Width; x++) + { + Color pixelColor = bitmap.GetPixel(x, y); + if (HueIsSame(pixelColor, targetColor, fuzz)) + { + bitmap.SetPixel(x, y, Color.Transparent); + } + } + } + + bitmap.Save(outputPath, ImageFormat.Png); + } + } + } + + /// + /// Erases the user preference color from an image. Will overwrite this image. + /// + /// The path to the image file. + /// The fuzz factor for color comparison, default is 2. + public static void EraseUserPreferenceColor(string imagePath, int fuzz = 2) + { + Color systemColor = GetSystemAccentColor(); + string tempPath = Path.GetTempFileName(); + MakeColorTransparent(imagePath, tempPath, systemColor, fuzz); + File.Delete(imagePath); + File.Move(tempPath, imagePath); + } + + /// + /// Compare two pixels with a fuzz factor + /// + /// base color + /// test color + /// fuzz factor, default is 10 + /// true if same, otherwise is false + public static bool PixIsSame(Color c1, Color c2, int fuzz = 10) + { + return Math.Abs(c1.A - c2.A) <= fuzz && Math.Abs(c1.R - c2.R) <= fuzz && Math.Abs(c1.G - c2.G) <= fuzz && Math.Abs(c1.B - c2.B) <= fuzz; + } + + /// + /// Compares the hue of two colors with a fuzz factor. + /// + /// The first color. + /// The second color. + /// The fuzz factor, default is 2. + /// True if the hues are the same, otherwise false. + public static bool HueIsSame(Color c1, Color c2, int fuzz = 2) + { + var h1 = GetHSL(c1).H; + var h2 = GetHSL(c2).H; + return Math.Abs(h1 - h2) <= fuzz; + } + } +} diff --git a/src/modules/Hosts/Hosts.UITests/Baseline/HostModuleTests_TestAddingEntry.png b/src/modules/Hosts/Hosts.UITests/Baseline/HostModuleTests_TestAddingEntry.png new file mode 100644 index 0000000000..9f3774215c Binary files /dev/null and b/src/modules/Hosts/Hosts.UITests/Baseline/HostModuleTests_TestAddingEntry.png differ diff --git a/src/modules/Hosts/Hosts.UITests/Baseline/HostModuleTests_TestEmptyView_EmptyView.png b/src/modules/Hosts/Hosts.UITests/Baseline/HostModuleTests_TestEmptyView_EmptyView.png new file mode 100644 index 0000000000..bf7b51fdc1 Binary files /dev/null and b/src/modules/Hosts/Hosts.UITests/Baseline/HostModuleTests_TestEmptyView_EmptyView.png differ diff --git a/src/modules/Hosts/Hosts.UITests/Baseline/HostModuleTests_TestEmptyView_NonEmptyView.png b/src/modules/Hosts/Hosts.UITests/Baseline/HostModuleTests_TestEmptyView_NonEmptyView.png new file mode 100644 index 0000000000..ed85e4c31c Binary files /dev/null and b/src/modules/Hosts/Hosts.UITests/Baseline/HostModuleTests_TestEmptyView_NonEmptyView.png differ diff --git a/src/modules/Hosts/Hosts.UITests/HostModuleTests.cs b/src/modules/Hosts/Hosts.UITests/HostModuleTests.cs index c50bbe988e..fd38283581 100644 --- a/src/modules/Hosts/Hosts.UITests/HostModuleTests.cs +++ b/src/modules/Hosts/Hosts.UITests/HostModuleTests.cs @@ -13,7 +13,7 @@ namespace Hosts.UITests public class HostModuleTests : UITestBase { public HostModuleTests() - : base(PowerToysModule.Hosts) + : base(PowerToysModule.Hosts, WindowSize.Small_Vertical) { } @@ -31,14 +31,16 @@ namespace Hosts.UITests /// /// /// - [TestMethod] + [TestMethod("Hosts.Basic.EmptyViewShouldWork")] public void TestEmptyView() { this.CloseWarningDialog(); this.RemoveAllEntries(); // 'Add an entry' button (only show-up when list is empty) should be visible - Assert.IsTrue(this.FindAll("Add an entry").Count == 1, "'Add an entry' button should be visible in the empty view"); + Assert.IsTrue(this.HasOne("Add an entry"), "'Add an entry' button should be visible in the empty view"); + + // VisualAssert.AreEqual(this.Find("Entries"), "EmptyView"); // Click 'Add an entry' from empty-view for adding Host override rule this.Find("Add an entry").Click(); @@ -46,8 +48,10 @@ namespace Hosts.UITests this.AddEntry("192.168.0.1", "localhost", false, false); // Should have one row now and not more empty view - Assert.IsTrue(this.FindAll