From 2d6ffefe204ba5cdec397955253c4ea2addbc484 Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Wed, 3 Jul 2024 09:06:27 +0200 Subject: [PATCH] Add changes from MAUI PR 19629 --- .../Actions/AppiumAndroidAlertActions.cs | 92 +++++++++++++++++++ .../Actions/AppiumAppleAlertActions.cs | 80 ++++++++++++++++ .../Actions/AppiumCatalystAlertActions.cs | 68 ++++++++++++++ .../Actions/AppiumIOSAlertActions.cs | 29 ++++++ .../AppiumAndroidApp.cs | 1 + .../AppiumCatalystApp.cs | 1 + .../AppiumIOSApp.cs | 1 + .../AppiumQuery.cs | 13 +++ .../HelperExtensions.cs | 77 +++++++++++++++- src/Plugin.Maui.UITestHelpers.Core/IQuery.cs | 1 + 10 files changed, 359 insertions(+), 4 deletions(-) create mode 100644 src/Plugin.Maui.UITestHelpers.Appium/Actions/AppiumAndroidAlertActions.cs create mode 100644 src/Plugin.Maui.UITestHelpers.Appium/Actions/AppiumAppleAlertActions.cs create mode 100644 src/Plugin.Maui.UITestHelpers.Appium/Actions/AppiumCatalystAlertActions.cs create mode 100644 src/Plugin.Maui.UITestHelpers.Appium/Actions/AppiumIOSAlertActions.cs diff --git a/src/Plugin.Maui.UITestHelpers.Appium/Actions/AppiumAndroidAlertActions.cs b/src/Plugin.Maui.UITestHelpers.Appium/Actions/AppiumAndroidAlertActions.cs new file mode 100644 index 00000000..94588a00 --- /dev/null +++ b/src/Plugin.Maui.UITestHelpers.Appium/Actions/AppiumAndroidAlertActions.cs @@ -0,0 +1,92 @@ +using OpenQA.Selenium.Appium; +using Plugin.Maui.UITestHelpers.Core; + +namespace Plugin.Maui.UITestHelpers.Appium; + +public class AppiumAndroidAlertActions : ICommandExecutionGroup +{ + const string GetAlertsCommand = "getAlerts"; + const string GetAlertButtonsCommand = "getAlertButtons"; + const string GetAlertTextCommand = "getAlertText"; + + readonly List _commands = new() + { + GetAlertsCommand, + GetAlertButtonsCommand, + GetAlertTextCommand, + }; + readonly AppiumApp _appiumApp; + + public AppiumAndroidAlertActions(AppiumApp appiumApp) + { + _appiumApp = appiumApp; + } + + public bool IsCommandSupported(string commandName) + { + return _commands.Contains(commandName, StringComparer.OrdinalIgnoreCase); + } + + public CommandResponse Execute(string commandName, IDictionary parameters) + { + return commandName switch + { + GetAlertsCommand => GetAlerts(parameters), + GetAlertButtonsCommand => GetAlertButtons(parameters), + GetAlertTextCommand => GetAlertText(parameters), + _ => CommandResponse.FailedEmptyResponse, + }; + } + + CommandResponse GetAlerts(IDictionary parameters) + { + var alerts = _appiumApp.Query.ById("parentPanel"); + + if (alerts is null || alerts.Count == 0) + return CommandResponse.FailedEmptyResponse; + + return new CommandResponse(alerts, CommandResponseResult.Success); + } + + CommandResponse GetAlertButtons(IDictionary parameters) + { + var alert = GetAppiumElement(parameters["element"]); + if (alert is null) + return CommandResponse.FailedEmptyResponse; + + var items = AppiumQuery.ByClass("android.widget.ListView") + .FindElements(alert, _appiumApp) + .FirstOrDefault() + ?.ByClass("android.widget.TextView"); + + var buttons = AppiumQuery.ByClass("android.widget.Button") + .FindElements(alert, _appiumApp); + + var all = new List(); + if (items is not null) + all.AddRange(items); + all.AddRange(buttons); + + return new CommandResponse(all, CommandResponseResult.Success); + } + + CommandResponse GetAlertText(IDictionary parameters) + { + var alert = GetAppiumElement(parameters["element"]); + if (alert is null) + return CommandResponse.FailedEmptyResponse; + + var text = AppiumQuery.ByClass("android.widget.TextView").FindElements(alert, _appiumApp); + var strings = text.Select(t => t.GetText()).ToList(); + + return new CommandResponse(strings, CommandResponseResult.Success); + } + + static AppiumElement? GetAppiumElement(object element) => + element switch + { + AppiumElement appiumElement => appiumElement, + AppiumDriverElement driverElement => driverElement.AppiumElement, + _ => null + }; +} \ No newline at end of file diff --git a/src/Plugin.Maui.UITestHelpers.Appium/Actions/AppiumAppleAlertActions.cs b/src/Plugin.Maui.UITestHelpers.Appium/Actions/AppiumAppleAlertActions.cs new file mode 100644 index 00000000..7a2c3268 --- /dev/null +++ b/src/Plugin.Maui.UITestHelpers.Appium/Actions/AppiumAppleAlertActions.cs @@ -0,0 +1,80 @@ +using OpenQA.Selenium.Appium; +using Plugin.Maui.UITestHelpers.Core; + +namespace Plugin.Maui.UITestHelpers.Appium; + +public abstract class AppiumAppleAlertActions : ICommandExecutionGroup +{ + const string GetAlertsCommand = "getAlerts"; + const string GetAlertButtonsCommand = "getAlertButtons"; + const string GetAlertTextCommand = "getAlertText"; + + readonly List _commands = new() + { + GetAlertsCommand, + GetAlertButtonsCommand, + GetAlertTextCommand, + }; + + protected readonly AppiumApp _appiumApp; + + public AppiumAppleAlertActions(AppiumApp appiumApp) + { + _appiumApp = appiumApp; + } + + public virtual bool IsCommandSupported(string commandName) => + _commands.Contains(commandName, StringComparer.OrdinalIgnoreCase); + + public virtual CommandResponse Execute(string commandName, IDictionary parameters) => + commandName switch + { + GetAlertsCommand => GetAlerts(parameters), + GetAlertButtonsCommand => GetAlertButtons(parameters), + GetAlertTextCommand => GetAlertText(parameters), + _ => CommandResponse.FailedEmptyResponse, + }; + + protected abstract IReadOnlyCollection OnGetAlerts(AppiumApp appiumApp, IDictionary parameters); + + CommandResponse GetAlerts(IDictionary parameters) + { + var alerts = OnGetAlerts(_appiumApp, parameters); + + if (alerts is null || alerts.Count == 0) + return CommandResponse.FailedEmptyResponse; + + return new CommandResponse(alerts, CommandResponseResult.Success); + } + + CommandResponse GetAlertButtons(IDictionary parameters) + { + var alert = GetAppiumElement(parameters["element"]); + if (alert is null) + return CommandResponse.FailedEmptyResponse; + + var buttons = AppiumQuery.ByClass("XCUIElementTypeButton").FindElements(alert, _appiumApp); + + return new CommandResponse(buttons, CommandResponseResult.Success); + } + + CommandResponse GetAlertText(IDictionary parameters) + { + var alert = GetAppiumElement(parameters["element"]); + if (alert is null) + return CommandResponse.FailedEmptyResponse; + + var text = AppiumQuery.ByClass("XCUIElementTypeStaticText").FindElements(alert, _appiumApp); + var strings = text.Select(t => t.GetText()).ToList(); + + return new CommandResponse(strings, CommandResponseResult.Success); + } + + protected static AppiumElement? GetAppiumElement(object element) => + element switch + { + AppiumElement appiumElement => appiumElement, + AppiumDriverElement driverElement => driverElement.AppiumElement, + _ => null + }; +} \ No newline at end of file diff --git a/src/Plugin.Maui.UITestHelpers.Appium/Actions/AppiumCatalystAlertActions.cs b/src/Plugin.Maui.UITestHelpers.Appium/Actions/AppiumCatalystAlertActions.cs new file mode 100644 index 00000000..42d4cc99 --- /dev/null +++ b/src/Plugin.Maui.UITestHelpers.Appium/Actions/AppiumCatalystAlertActions.cs @@ -0,0 +1,68 @@ +using Plugin.Maui.UITestHelpers.Core; + +namespace Plugin.Maui.UITestHelpers.Appium; + +public class AppiumCatalystAlertActions : AppiumAppleAlertActions +{ + // Selects the inner "popover contents" of a popover window. + const string PossibleActionSheetXPath = + "/XCUIElementTypeApplication/XCUIElementTypeWindow/XCUIElementTypePopover"; + + const string DismissAlertCommand = "dismissAlert"; + + readonly List _commands = new() + { + DismissAlertCommand, + }; + + public AppiumCatalystAlertActions(AppiumApp appiumApp) + : base(appiumApp) + { + } + + public override bool IsCommandSupported(string commandName) => + _commands.Contains(commandName, StringComparer.OrdinalIgnoreCase) || base.IsCommandSupported(commandName); + + public override CommandResponse Execute(string commandName, IDictionary parameters) => + commandName switch + { + DismissAlertCommand => DismissAlert(parameters), + _ => base.Execute(commandName, parameters), + }; + + CommandResponse DismissAlert(IDictionary parameters) + { + var alert = GetAppiumElement(parameters["element"]); + if (alert is null) + return CommandResponse.FailedEmptyResponse; + + // XCUIElementTypePopover == 18 + if (!"18".Equals(alert.GetAttribute("elementType"), StringComparison.OrdinalIgnoreCase)) + return CommandResponse.FailedEmptyResponse; + + var dismissRegions = AppiumQuery.ById("PopoverDismissRegion").FindElements(_appiumApp).ToList(); + for (var i = dismissRegions.Count - 1; i >= 0; i--) + { + var region = GetAppiumElement(dismissRegions[i])!; + if ("true".Equals(region.GetAttribute("enabled"), StringComparison.OrdinalIgnoreCase)) + { + region.Click(); + return CommandResponse.SuccessEmptyResponse; + } + } + + return CommandResponse.FailedEmptyResponse; + } + + protected override IReadOnlyCollection OnGetAlerts(AppiumApp appiumApp, IDictionary parameters) + { + // Catalyst uses action sheets for alerts and macOS 14 + var alerts = appiumApp.FindElements(AppiumQuery.ByClass("XCUIElementTypeSheet")); + + // But it also uses popovers for action sheets on macOS 13 + if (alerts is null || alerts.Count == 0) + alerts = appiumApp.FindElements(AppiumQuery.ByXPath(PossibleActionSheetXPath)); + + return alerts; + } +} \ No newline at end of file diff --git a/src/Plugin.Maui.UITestHelpers.Appium/Actions/AppiumIOSAlertActions.cs b/src/Plugin.Maui.UITestHelpers.Appium/Actions/AppiumIOSAlertActions.cs new file mode 100644 index 00000000..82a16d2f --- /dev/null +++ b/src/Plugin.Maui.UITestHelpers.Appium/Actions/AppiumIOSAlertActions.cs @@ -0,0 +1,29 @@ +using Plugin.Maui.UITestHelpers.Core; + +namespace Plugin.Maui.UITestHelpers.Appium; + +public class AppiumIOSAlertActions : AppiumAppleAlertActions +{ + // Selects VISIBLE "Other" elements that are the direct child of + // a VISIBLE window AND are OVERLAYED on top of the first window. + const string PossibleAlertXPath = + "//XCUIElementTypeWindow[@visible='true']/XCUIElementTypeOther[@visible='true' and @index > 0]"; + + public AppiumIOSAlertActions(AppiumApp appiumApp) + : base(appiumApp) + { + } + + protected override IReadOnlyCollection OnGetAlerts(AppiumApp appiumApp, IDictionary parameters) + { + // First try the type used on iOS. + var alerts = appiumApp.FindElements(AppiumQuery.ByClass("XCUIElementTypeAlert")); + + // It appears iOS sometimes uses the XCUIElementTypeOther class for action sheets + // so we need a way to do a more fuzzy check. + if (alerts is null || alerts.Count == 0) + alerts = appiumApp.FindElements(AppiumQuery.ByXPath(PossibleAlertXPath)); + + return alerts; + } +} \ No newline at end of file diff --git a/src/Plugin.Maui.UITestHelpers.Appium/AppiumAndroidApp.cs b/src/Plugin.Maui.UITestHelpers.Appium/AppiumAndroidApp.cs index 1eb0dbeb..5d0bebee 100644 --- a/src/Plugin.Maui.UITestHelpers.Appium/AppiumAndroidApp.cs +++ b/src/Plugin.Maui.UITestHelpers.Appium/AppiumAndroidApp.cs @@ -11,6 +11,7 @@ private AppiumAndroidApp(Uri remoteAddress, IConfig config) : base(new AndroidDriver(remoteAddress, GetOptions(config)), config) { _commandExecutor.AddCommandGroup(new AppiumAndroidVirtualKeyboardActions(this)); + _commandExecutor.AddCommandGroup(new AppiumAndroidAlertActions(this)); } public static AppiumAndroidApp CreateAndroidApp(Uri remoteAddress, IConfig config) diff --git a/src/Plugin.Maui.UITestHelpers.Appium/AppiumCatalystApp.cs b/src/Plugin.Maui.UITestHelpers.Appium/AppiumCatalystApp.cs index 38c81ba7..e2a537a9 100644 --- a/src/Plugin.Maui.UITestHelpers.Appium/AppiumCatalystApp.cs +++ b/src/Plugin.Maui.UITestHelpers.Appium/AppiumCatalystApp.cs @@ -12,6 +12,7 @@ public AppiumCatalystApp(Uri remoteAddress, IConfig config) { _commandExecutor.AddCommandGroup(new AppiumCatalystMouseActions(this)); _commandExecutor.AddCommandGroup(new AppiumCatalystTouchActions(this)); + _commandExecutor.AddCommandGroup(new AppiumCatalystAlertActions(this)); } public override ApplicationState AppState diff --git a/src/Plugin.Maui.UITestHelpers.Appium/AppiumIOSApp.cs b/src/Plugin.Maui.UITestHelpers.Appium/AppiumIOSApp.cs index 7a4826dd..b04dbba3 100644 --- a/src/Plugin.Maui.UITestHelpers.Appium/AppiumIOSApp.cs +++ b/src/Plugin.Maui.UITestHelpers.Appium/AppiumIOSApp.cs @@ -14,6 +14,7 @@ public AppiumIOSApp(Uri remoteAddress, IConfig config) _commandExecutor.AddCommandGroup(new AppiumIOSMouseActions(this)); _commandExecutor.AddCommandGroup(new AppiumIOSTouchActions(this)); _commandExecutor.AddCommandGroup(new AppiumIOSVirtualKeyboardActions(this)); + _commandExecutor.AddCommandGroup(new AppiumIOSAlertActions(this)); } public override ApplicationState AppState diff --git a/src/Plugin.Maui.UITestHelpers.Appium/AppiumQuery.cs b/src/Plugin.Maui.UITestHelpers.Appium/AppiumQuery.cs index d8081378..0f72c7fe 100644 --- a/src/Plugin.Maui.UITestHelpers.Appium/AppiumQuery.cs +++ b/src/Plugin.Maui.UITestHelpers.Appium/AppiumQuery.cs @@ -15,7 +15,9 @@ public class AppiumQuery : IQuery const string IdQuery = IdToken + "={0}"; const string NameQuery = NameToken + "={0}"; const string AccessibilityQuery = AccessibilityToken + "={0}"; + const string XPathToken = "xpath"; const string ClassQuery = ClassToken + "={0}"; + const string XPathQuery = XPathToken + "={0}"; readonly string _queryStr; public AppiumQuery(string queryStr) @@ -48,6 +50,11 @@ IQuery IQuery.ByName(string nameQuery) return new AppiumQuery(this, string.Format(NameQuery, nameQuery)); } + IQuery IQuery.ByXPath(string xpath) + { + return new AppiumQuery(this, string.Format(XPathQuery, Uri.EscapeDataString(xpath))); + } + public static AppiumQuery ById(string id) { return new AppiumQuery(string.Format(IdQuery, id)); @@ -58,6 +65,11 @@ public static AppiumQuery ByName(string nameQuery) return new AppiumQuery(string.Format(NameQuery, nameQuery)); } + public static AppiumQuery ByXPath(string xpath) + { + return new AppiumQuery(string.Format(XPathQuery, Uri.EscapeDataString(xpath))); + } + public static AppiumQuery ByAccessibilityId(string id) { return new AppiumQuery(string.Format(AccessibilityQuery, id)); @@ -184,6 +196,7 @@ private static By GetQueryBy(string token, string value) NameToken => MobileBy.Name(value), AccessibilityToken => MobileBy.AccessibilityId(value), IdToken => MobileBy.Id(value), + XPathToken => MobileBy.XPath(Uri.UnescapeDataString(value)), _ => throw new ArgumentException("Unknown query type"), }; } diff --git a/src/Plugin.Maui.UITestHelpers.Appium/HelperExtensions.cs b/src/Plugin.Maui.UITestHelpers.Appium/HelperExtensions.cs index b8e041cb..6fd65fd7 100644 --- a/src/Plugin.Maui.UITestHelpers.Appium/HelperExtensions.cs +++ b/src/Plugin.Maui.UITestHelpers.Appium/HelperExtensions.cs @@ -298,6 +298,63 @@ public static void ScrollTo(this IApp app, string toElementId, bool down = true) }); } + /// + /// Return the currently presented alert or action sheet. + /// + /// Represents the main gateway to interact with an app. + public static IUIElement? GetAlert(this IApp app) + { + return app.GetAlerts().FirstOrDefault(); + } + + /// + /// Return the currently presented alerts or action sheets. + /// + /// Represents the main gateway to interact with an app. + public static IReadOnlyCollection GetAlerts(this IApp app) + { + var result = app.CommandExecutor.Execute("getAlerts", ImmutableDictionary.Empty); + return (IReadOnlyCollection?)result.Value ?? Array.Empty(); + } + + /// + /// Dismisses the alert. + /// + /// The element that represents the alert or action sheet. + public static void DismissAlert(this IUIElement alertElement) + { + alertElement.Command.Execute("dismissAlert", new Dictionary + { + ["element"] = alertElement + }); + } + + /// + /// Return the buttons in the alert or action sheet. + /// + /// The element that represents the alert or action sheet. + public static IReadOnlyCollection GetAlertButtons(this IUIElement alertElement) + { + var result = alertElement.Command.Execute("getAlertButtons", new Dictionary + { + ["element"] = alertElement + }); + return (IReadOnlyCollection?)result.Value ?? Array.Empty(); + } + + /// + /// Return the text messages in the alert or action sheet. + /// + /// The element that represents the alert or action sheet. + public static IReadOnlyCollection GetAlertText(this IUIElement alertElement) + { + var result = alertElement.Command.Execute("getAlertText", new Dictionary + { + ["element"] = alertElement + }); + return (IReadOnlyCollection?)result.Value ?? Array.Empty(); + } + public static IUIElement WaitForElement(this IApp app, string marked, string timeoutMessage = "Timed out waiting for element...", TimeSpan? timeout = null, TimeSpan? retryFrequency = null, TimeSpan? postTimeout = null) { IUIElement result() => app.FindElement(marked); @@ -306,12 +363,24 @@ public static void ScrollTo(this IApp app, string toElementId, bool down = true) return results; } + public static IUIElement WaitForElement(this IApp app, Func query, string? timeoutMessage = null, TimeSpan? timeout = null, TimeSpan? retryFrequency = null) + { + var results = Wait(query, i => i != null, timeoutMessage, timeout, retryFrequency); + + return results; + } + public static void WaitForNoElement(this IApp app, string marked, string timeoutMessage = "Timed out waiting for no element...", TimeSpan? timeout = null, TimeSpan? retryFrequency = null, TimeSpan? postTimeout = null) { IUIElement result() => app.FindElement(marked); WaitForNone(result, timeoutMessage, timeout, retryFrequency); } + public static void WaitForNoElement(this IApp app, Func query, string? timeoutMessage = null, TimeSpan? timeout = null, TimeSpan? retryFrequency = null) + { + Wait(query, i => i is null, timeoutMessage, timeout, retryFrequency); + } + public static bool WaitForTextToBePresentInElement(this IApp app, string automationId, string text) { TimeSpan timeout = DefaultTimeout; @@ -762,8 +831,8 @@ public static bool IsFocused(this IApp app, string id) return element.AppiumElement.Equals(activeElement); } - static IUIElement Wait(Func query, - Func satisfactory, + static IUIElement Wait(Func query, + Func satisfactory, string? timeoutMessage = null, TimeSpan? timeout = null, TimeSpan? retryFrequency = null) { @@ -773,7 +842,7 @@ static IUIElement Wait(Func query, DateTime start = DateTime.Now; - IUIElement result = query(); + IUIElement? result = query(); while (!satisfactory(result)) { @@ -789,7 +858,7 @@ static IUIElement Wait(Func query, result = query(); } - return result; + return result!; } static IUIElement WaitForAtLeastOne(Func query, diff --git a/src/Plugin.Maui.UITestHelpers.Core/IQuery.cs b/src/Plugin.Maui.UITestHelpers.Core/IQuery.cs index 57c9c968..0ad2b7de 100644 --- a/src/Plugin.Maui.UITestHelpers.Core/IQuery.cs +++ b/src/Plugin.Maui.UITestHelpers.Core/IQuery.cs @@ -6,5 +6,6 @@ public interface IQuery IQuery ByName(string name); IQuery ByClass(string className); IQuery ByAccessibilityId(string id); + IQuery ByXPath(string xpath); } }