diff --git a/Assets/AltTester/Runtime/AltDriver/AltAppiumHelper.cs b/Assets/AltTester/Runtime/AltDriver/AltAppiumHelper.cs new file mode 100644 index 000000000..a0bda59cb --- /dev/null +++ b/Assets/AltTester/Runtime/AltDriver/AltAppiumHelper.cs @@ -0,0 +1,168 @@ +/* + Copyright(C) 2025 Altom Consulting + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +#if !UNITY_EDITOR +using System; +using OpenQA.Selenium; +using OpenQA.Selenium.Appium; +using OpenQA.Selenium.Support.UI; +using SeleniumExtras.WaitHelpers; + +namespace AltTester.AltTesterSDK.Driver +{ + /// + /// API to interact with the native popup dialog (implemented by AltTester) using Appium + /// + public class AltAppiumHelper + { + /// + /// Sets connection data in the native popup dialog + /// + /// The Appium driver instance + /// The platform (Android or iOS) + /// The host value to set. If not provided, the host field won't be updated + /// The port value to set. If not provided, the port field won't be updated + /// The app name value to set. If not provided, the app name field won't be updated + /// Timeout in seconds for waiting for elements (default: 60) + /// Thrown when appiumDriver is null + /// Thrown when no fields are provided for update + /// Thrown when host or port is invalid + /// Thrown when an error occurs while interacting with the popup + public static void SetConnectionData(AppiumDriver appiumDriver, string platform, string host = null, string port = null, string appName = null, int timeout = 60) + { + if (appiumDriver == null) + { + throw new ArgumentNullException(nameof(appiumDriver), "Appium driver cannot be null"); + } + + // Check that at least one field is provided + if (string.IsNullOrEmpty(host) && string.IsNullOrEmpty(port) && string.IsNullOrEmpty(appName)) + { + throw new ArgumentException("At least one of 'host', 'port', or 'appName' must be provided"); + } + + // Validate connection data + if (!string.IsNullOrEmpty(host) && Uri.CheckHostName(host) == UriHostNameType.Unknown) + { + throw new ArgumentException($"Invalid host: {host}. The host should be a valid host."); + } + + if (!string.IsNullOrEmpty(port)) + { + if (!int.TryParse(port, out int portInt) || portInt <= 0 || portInt > 65535) + { + throw new ArgumentException($"Invalid port: {port}. The port number should be between 1 and 65535."); + } + } + + if (!platform.Equals("Android", StringComparison.OrdinalIgnoreCase) && + !platform.Equals("iOS", StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException($"Unsupported platform: {platform}. Supported platforms are 'Android' and 'iOS'."); + } + + try + { + // Set XPath based on platform + string hostXPath = platform.Equals("iOS", StringComparison.OrdinalIgnoreCase) + ? "//XCUIElementTypeTextField[@value=\"Host\"]" + : "//android.widget.EditText[@text=\"Host\"]"; + + string portXPath = platform.Equals("iOS", StringComparison.OrdinalIgnoreCase) + ? "//XCUIElementTypeTextField[@value=\"Port\"]" + : "//android.widget.EditText[@text=\"Port\"]"; + + string appNameXPath = platform.Equals("iOS", StringComparison.OrdinalIgnoreCase) + ? "//XCUIElementTypeTextField[@value=\"App Name\"]" + : "//android.widget.EditText[@text=\"App Name\"]"; + + string okButtonXPath = platform.Equals("iOS", StringComparison.OrdinalIgnoreCase) + ? "//XCUIElementTypeButton[@name=\"OK\"]" + : "//android.widget.Button[@resource-id=\"android:id/button1\"]"; + + // Wait for the connection dialog to be present + var wait = new WebDriverWait(appiumDriver, TimeSpan.FromSeconds(timeout)); + wait.Until(ExpectedConditions.ElementExists(OpenQA.Selenium.By.XPath(hostXPath))); + + // Update host if provided + if (!string.IsNullOrEmpty(host)) + { + var hostField = appiumDriver.FindElement(OpenQA.Selenium.By.XPath(hostXPath)); + hostField.Clear(); + hostField.SendKeys(host); + } + + // Update port if provided + if (!string.IsNullOrEmpty(port)) + { + var portField = appiumDriver.FindElement(OpenQA.Selenium.By.XPath(portXPath)); + portField.Clear(); + portField.SendKeys(port); + } + + // Update app name if provided + if (!string.IsNullOrEmpty(appName)) + { + var appNameField = appiumDriver.FindElement(OpenQA.Selenium.By.XPath(appNameXPath)); + appNameField.Clear(); + appNameField.SendKeys(appName); + } + + // Press OK button + var okButton = appiumDriver.FindElement(OpenQA.Selenium.By.XPath(okButtonXPath)); + okButton.Click(); + } + catch (ArgumentException) + { + throw; + } + catch (Exception ex) + { + throw new AppiumHelperException($"Error while setting connection data on {platform} platform: {ex.Message}", ex); + } + } + + /// + /// Sets connection data in the native popup dialog with integer port + /// + /// The Appium driver instance + /// The platform (Android or iOS) + /// The host value to set. If not provided, the host field won't be updated + /// The port value to set. If not provided, the port field won't be updated + /// The app name value to set. If not provided, the app name field won't be updated + /// Timeout in seconds for waiting for elements (default: 60) + public static void SetConnectionData(AppiumDriver appiumDriver, string platform, string host = null, int? port = null, string appName = null, int timeout = 60) + { + SetConnectionData(appiumDriver, platform, host, port?.ToString(), appName, timeout); + } + } + + /// + /// Exception thrown when an error occurs while using AltAppiumHelper + /// + public class AppiumHelperException : Exception + { + public AppiumHelperException(string message) : base(message) + { + } + + public AppiumHelperException(string message, Exception innerException) : base(message, innerException) + { + } + } +} +#endif diff --git a/Bindings~/dotnet/AltDriver/AltDriver.csproj b/Bindings~/dotnet/AltDriver/AltDriver.csproj index 05f9cdba3..02a4760f4 100644 --- a/Bindings~/dotnet/AltDriver/AltDriver.csproj +++ b/Bindings~/dotnet/AltDriver/AltDriver.csproj @@ -31,5 +31,7 @@ + + diff --git a/Bindings~/python/alttester/__init__.py b/Bindings~/python/alttester/__init__.py index bda18de49..4805e44be 100644 --- a/Bindings~/python/alttester/__init__.py +++ b/Bindings~/python/alttester/__init__.py @@ -23,3 +23,4 @@ from alttester.logging import AltLogLevel, AltLogger from alttester.exceptions import * from alttester.reverse_port_forwarding import * +from alttester.appium_helper import * diff --git a/Bindings~/python/alttester/appium_helper.py b/Bindings~/python/alttester/appium_helper.py new file mode 100644 index 000000000..c611ba8b7 --- /dev/null +++ b/Bindings~/python/alttester/appium_helper.py @@ -0,0 +1,124 @@ +""" + Copyright(C) 2025 Altom Consulting + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +""" + +import re +import ipaddress + +from appium.webdriver.common.appiumby import AppiumBy +from selenium.webdriver.support.ui import WebDriverWait +from selenium.webdriver.support import expected_conditions as EC + + +class AltAppiumHelper: + """API to interact with the native popup dialog (implemented by AltTester) using Appium""" + + @staticmethod + def _is_valid_host(host): + if not host or not isinstance(host, str): + return False + + # Try to parse as IP address (IPv4 or IPv6) + try: + ipaddress.ip_address(host) + return True + except ValueError: + pass + + # Validate as hostname + # Hostname pattern: labels separated by dots, each label contains alphanumeric and hyphens + # Label cannot start or end with hyphen, max 63 characters per label + hostname_pattern = r'^(?!-)[a-zA-Z0-9-]{1,63}(? 65535: + raise ValueError(f"Invalid port: {port_int}. The port number should be between 1 and 65535.") + + try: + # Set XPath based on platform + if platform.lower() == 'ios': + host_xpath = '//XCUIElementTypeTextField[@value="Host"]' + port_xpath = '//XCUIElementTypeTextField[@value="Port"]' + app_name_xpath = '//XCUIElementTypeTextField[@value="App Name"]' + ok_button_xpath = '//XCUIElementTypeButton[@name="OK"]' + elif platform.lower() == 'android': + host_xpath = '//android.widget.EditText[@text="Host"]' + port_xpath = '//android.widget.EditText[@text="Port"]' + app_name_xpath = '//android.widget.EditText[@text="App Name"]' + ok_button_xpath = '//android.widget.Button[@resource-id="android:id/button1"]' + else: + raise ValueError(f"Unsupported platform: {platform}. Supported platforms are 'android' and 'ios'.") + + # Wait for the connection dialog to be present + wait = WebDriverWait(appium_driver, timeout) + wait.until(EC.presence_of_element_located((AppiumBy.XPATH, host_xpath))) + + # Update host if provided + if host is not None: + host_field = appium_driver.find_element(AppiumBy.XPATH, host_xpath) + host_field.clear() + host_field.send_keys(host) + + # Update port if provided + if port is not None: + port_field = appium_driver.find_element(AppiumBy.XPATH, port_xpath) + port_field.clear() + port_field.send_keys(port_str) + + # Update app_name if provided + if app_name is not None: + app_name_field = appium_driver.find_element(AppiumBy.XPATH, app_name_xpath) + app_name_field.clear() + app_name_field.send_keys(app_name) + + # Press OK button + ok_button = appium_driver.find_element(AppiumBy.XPATH, ok_button_xpath) + ok_button.click() + + except Exception as ex: + raise Exception(f"Error while setting connection data on {platform} platform: {str(ex)}") from ex diff --git a/Bindings~/python/alttester/reverse_port_forwarding.py b/Bindings~/python/alttester/reverse_port_forwarding.py index 315bf340b..7b842fa8a 100644 --- a/Bindings~/python/alttester/reverse_port_forwarding.py +++ b/Bindings~/python/alttester/reverse_port_forwarding.py @@ -1,6 +1,5 @@ """ Copyright(C) 2025 Altom Consulting - Copyright(C) 2025 Altom Consulting This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by